1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
# Agent State

Reports the session's blocked/working/done/idle state to `,agent-state`, so
pi sessions appear in the `,agents` picker (prefix+A) and the sketchybar
agents item alongside Claude Code ones.

## How it works

Maps pi lifecycle events to states, following the design of herdr's
bundled pi integration:

| Event | State |
|-------|-------|
| `agent_start` | working |
| `agent_settled` (when actually idle) | done, "turn finished" |
| `session_start` | republish current state (reload-safe) |
| `session_shutdown` | clear the pane's row |

Active only in TUI mode inside tmux — RPC/JSON/print modes are headless,
and the tmux pane is the row's identity. Writes go through a serial
fire-and-forget queue to `~/.dotfiles/ai/bin/,agent-state`, then poke
sketchybar to redraw.

## Blocked state

pi has no built-in permission prompt, so `permission-gate.ts` emits
`agent-state:blocked` events on the shared extension event bus around its
dialogs, and this extension records `blocked` for their duration.

# Ask User

Gives the agent an `ask_user` tool that asks the user a question without
blocking: the tool returns immediately with a pending id, the question
opens as a dialog in parallel with the agent's turn, and the answer is
delivered later as a user message tagged `[ask_user <id>]` that repeats the
question, so the model correlates them by id.

## How it works

`execute()` fires the dialog without awaiting it and returns at once with
an id (four random characters). When the user answers, the extension calls
`pi.sendUserMessage(text, { deliverAs: "steer" })`: while the agent is
mid-run the message queues as steering and enters after the current
assistant turn; if the agent already went idle, it starts a fresh turn.

Built on the `ctx.ui` dialogs rather than a custom component, so it works
in the TUI and over RPC. With `options`, the user picks from a list with
an "Other…" escape hatch that opens a text input; without, the user types
a free-form answer. Dismissing the dialog sends a message saying the
question was dismissed, so the model knows to re-ask or proceed. Dialogs
are serialized (a module-level promise chain) because a second `ctx.ui`
dialog disposes the first without resolving it. In headless modes (JSON,
print) the tool returns an error telling the agent to ask in its reply
instead.

`permission-gate.ts` allowlists `ask_user` — gating a question behind a
confirmation dialog would just be a second question.

# Permission Gate

Requires explicit user confirmation before every tool call, with
declarative allowlists for commands that can run without prompting.

## How It Works

**Read tool** — auto-allowed for files tracked by `jj`, files in skills
directories, and pi documentation.

**Bash tool** — auto-allowed only if the command matches the allowlist
and contains no shell chaining (`|`, `&&`, `;`, `` ` ``, `$()`).

Everything else prompts for confirmation, except `ask_user` (dialog-only,
no side effects).

## Base Allowlist

The `BASE_COMMANDS` tree in `permission-gate.ts` defines the global
allowlist. Each entry maps a command to a rule:

```ts
const BASE_COMMANDS: CommandRule = {
  jj: ["diff", "log", "show", "status"],
};
```

### Rule Types

| Rule | Meaning | Example |
|------|---------|---------|
| `true` | Allow with any arguments | `ls: true` |
| `string[]` | Allow only these subcommands | `jj: ["diff", "log"]` |
| `{ ... }` | Recurse into subcommands | `gh: { pr: ["list", "view"] }` |
| `(args) => boolean` | Custom predicate | `rake: (a) => a.startsWith("-T")` |

Rules are recursive — arrays and objects can nest to any depth:

```ts
{
  gh: {
    pr:    ["list", "view", "checks"],
    issue: { list: true, view: true },
    repo:  true,                        // all repo subcommands
  },
}
```

`gh pr list --author me` walks `gh` → `pr` → `list` → `true` → allowed.
`gh pr create` walks `gh` → `pr` → `create` → undefined → prompted.

## Per-Project Overrides

Projects can extend the allowlist by placing a `.pi/permissions.json`
in the project root (next to `.pi/`):

```json
{
  "allow": {
    "cargo": ["test", "check", "clippy"],
    "make": ["test", "lint"],
    "docker": {
      "compose": ["up", "down", "ps"]
    }
  }
}
```

### Trust Model

Project overrides are **not trusted automatically**. On the first tool
call of a session (or when the file changes), the user sees a
confirmation prompt:

```
Project wants to auto-allow:
  cargo test
  cargo check
  cargo clippy
  make test
  make lint
  docker compose up
  docker compose down
  docker compose ps

Allow? [y/n]
```

Approvals are persisted in `~/.pi/agent/approved-permissions.json`,
keyed by project directory and a SHA-256 hash of the file content.
If `.pi/permissions.json` is modified, the hash won't match and the
user is re-prompted.

### Merge Behavior

Project rules are **additive only** — they are deep-merged with the
base allowlist. A project cannot narrow or remove base permissions.
If the base already grants `true` at some node, the project's rules
for that node are ignored (it's already fully allowed).

### JSON-Only

Project permissions use `JsonCommandRule`, which supports `true`,
`string[]`, and nested objects — but not function predicates (since
the file is JSON). Predicates are only available in `BASE_COMMANDS`.

### Non-Interactive Mode

If pi is running without a UI (e.g., RPC or print mode), project
permissions that haven't been previously approved are silently skipped.