@giladbarnea/pi-user-agents 0.0.3 → 0.0.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/INTERNALS.md +41 -4
- package/PARSER_SPEC.md +3 -3
- package/README.md +89 -24
- package/agent-viewer.ts +73 -20
- package/command-line.ts +10 -10
- package/index.ts +23 -11
- package/package.json +1 -1
- package/rebase.ts +56 -0
- package/runner.ts +160 -11
- package/shared.ts +94 -9
- package/transcript.ts +49 -14
- package/widget.ts +121 -56
package/INTERNALS.md
CHANGED
|
@@ -16,13 +16,13 @@ How a finished run reaches (or hides from) the main agent rests on two Pi SDK ch
|
|
|
16
16
|
|
|
17
17
|
- **`pi.appendEntry(customType, data)` + `pi.registerEntryRenderer`** — the only channel that renders in the transcript while staying out of LLM context *forever*. Entries are persisted in the session file and re-rendered on reload. The entry's `data` is optional in the type — renderers must guard it.
|
|
18
18
|
|
|
19
|
-
This extension maps the two extremes onto the `-
|
|
19
|
+
This extension maps the two extremes onto the `-s` flag: the default posts the result card via `appendEntry` (main agent structurally oblivious), and `-s` posts a visible custom message with `{ triggerTurn: true }` (steered mid-turn or answered immediately). Both channels share one card renderer in `transcript.ts`.
|
|
20
20
|
|
|
21
|
-
Immediate `-
|
|
21
|
+
Immediate `-s` delivery and late `s` squash delivery share one canonical message builder. Its `display` option prevents a second visible result card. Both paths use the same selected conversation and `{ triggerTurn: true }` delivery. For a run launched without `-s`, the context message stays hidden at squash time because the TUI-only result card is already visible.
|
|
22
22
|
|
|
23
|
-
##
|
|
23
|
+
## Squashed message format
|
|
24
24
|
|
|
25
|
-
The
|
|
25
|
+
The squashed text starts with this sentence:
|
|
26
26
|
|
|
27
27
|
> The user has dispatched a background sub-agent with a task. The sub-agent is done. The following is the back and forth between them:
|
|
28
28
|
|
|
@@ -30,6 +30,43 @@ A plain `<user_agent>` tag follows it. The tag contains only the model and inher
|
|
|
30
30
|
|
|
31
31
|
One selection rule builds the body. It keeps every user message. Before each next user message, it keeps only the latest assistant response. It applies the same rule after the final user message. Tool calls, thinking, tool results, and other message roles stay out.
|
|
32
32
|
|
|
33
|
+
## Rebase
|
|
34
|
+
|
|
35
|
+
`r` fast-forwards the raw child conversation onto the dispatching session. Three Pi facts shape the mechanism:
|
|
36
|
+
|
|
37
|
+
1. The main agent's next LLM call reads the in-memory `agent.state.messages`, not the session file, and the TUI transcript renders from `AgentSession` events, not from the `SessionManager`. Appending entries alone reaches neither.
|
|
38
|
+
2. Extensions have no path to the main `AgentSession`, so the state cannot be pushed in place.
|
|
39
|
+
3. On `ctx.switchSession()` the host rebuilds both from the file — `agent.state.messages` from `buildSessionContext()` and the transcript from `buildContextEntries()` — while tearing the extension runtime down (`session_shutdown`, then a fresh extension instance).
|
|
40
|
+
|
|
41
|
+
Delivery therefore appends the processed messages through the session manager (`ctx.sessionManager` is typed `ReadonlySessionManager`, but the runtime object is the writable `SessionManager` — one cast), drops a persisted `pi-user-agents-rebased` breadcrumb entry naming the child session, and calls `ctx.switchSession(<current session file>)`. Not `ctx.reload()` — that one reloads extensions and resources only and never re-reads the session file.
|
|
42
|
+
|
|
43
|
+
Message processing (`selectRebaseMessages`) keeps the child's history verbatim, with two exceptions: the first user message becomes the plain task (dropping the internal background-process prefix), and this extension's own custom messages (`pi-user-agents`) are dropped — they are the one trace of the user-agents machinery. Everything else the child saw passes through, other extensions' custom messages included.
|
|
44
|
+
|
|
45
|
+
A child compaction passes through deliberately and lands as a real `compaction` entry mid-file. `buildContextEntries` treats the latest compaction on the path as the context boundary, so after the switch main's LLM context is exactly the child's live context — summary, kept tail, then everything after — while the transcript keeps the full history. Dropping it would instead graft an over-window context onto main and force a fresh, lossy auto-compaction.
|
|
46
|
+
|
|
47
|
+
Two mechanics make that exact. First, a live compaction emits no message event — only `compaction_end` — so the child subscription captures it there as a synthetic `compactionSummary` message, or the append-only conversation would never contain it. Second, the kept-tail boundary (`firstKeptEntryId`) is a child-session entry id that means nothing in the main session's file; but at `compaction_end` the child's live context is exactly `[summary, kept tail]`, so the capture records `keptTailCount`, and the rebase replay counts back that many context messages to pass the main-session id of the message that starts the tail. The tail may reach past the replayed messages into main's pre-existing context — a child that inherits a nearly full context compacts early — so the delivery prepends main's own per-message entry ids to the count.
|
|
48
|
+
|
|
49
|
+
The fast-forward precondition is fingerprint equality: at dispatch the agent stores `JSON.stringify` of the inherited context messages (`[]` for `-i`), and `r` is offered only while the main session's current `buildSessionContext().messages` fingerprint still matches. The live fingerprint is cached per session manager and recomputed only when the branch leaf moves, so TUI-only entries (result cards, detach lines) move the leaf, trigger one recompute, and correctly do not block the rebase. One rule covers isolated agents too: their base is empty, so they rebase only onto a still-empty session.
|
|
50
|
+
|
|
51
|
+
Because the session switch tears the extension down, live widget rows cannot survive it: `r` is withheld while any agent is mid-turn, and the remaining parked and completed agents are detached first through the ordinary detach paths, so every one of them leaves its `Detached session …` breadcrumb before the switch. The switch re-opens the same file, so the session id does not change.
|
|
52
|
+
|
|
53
|
+
## Child session persistence
|
|
54
|
+
|
|
55
|
+
Every dispatched agent gets a persisted `SessionManager.create(cwd, undefined, { parentSession })` rather than an in-memory one, so its conversation is an ordinary Pi session file from birth. `parentSession` is a *path*, and it records the main session that dispatched the agent — the same field `/fork` uses, surfaced by `SessionManager.list()` as `parentSessionPath`. There is no API to promote an in-memory session to disk later: `SessionManager` fixes `persist` at construction, and both `_persist()` and `_rewriteFile()` return early when it is false, so `setSessionFile()` on an in-memory manager sets a path and writes nothing.
|
|
56
|
+
|
|
57
|
+
Two ordering constraints follow from `createAgentSession`:
|
|
58
|
+
|
|
59
|
+
1. **Build the session while its manager is still empty.** Pi writes the `model_change` and `thinking_level_change` entries only on the empty-session path. Seed messages first and those entries never appear, so a resumed child falls back to `findInitialModel` instead of the model it was dispatched with.
|
|
60
|
+
2. **Route the inherited snapshot through the session manager.** Persistence runs off message events, so the old `session.agent.state.messages = inherited` assignment never reached the file. `persistMessages` appends it entry by entry, then the agent state is set to the same list.
|
|
61
|
+
|
|
62
|
+
That snapshot is already compaction-resolved, so at most one `compactionSummary` leads it. Each role takes its own append method — `appendCompaction`, `branchWithSummary`, `appendCustomMessageEntry`, `appendMessage` — because pi's own compaction and branch bookkeeping locates boundaries by entry type, and a summary written as a plain message entry would be invisible to it. `appendCompaction` wants a `firstKeptEntryId` the replay cannot know, but `buildContextEntries` only consults it for entries *before* the compaction entry; a leading compaction has none, so the value is never read.
|
|
63
|
+
|
|
64
|
+
The file itself appears on the child's first assistant message (`_persist` defers until then). An agent dispatched with `-i` that never answers therefore leaves nothing behind, while an agent that inherited context has a file immediately.
|
|
65
|
+
|
|
66
|
+
## Detaching
|
|
67
|
+
|
|
68
|
+
`d` `d` ends the live child session and drops its widget row. The session file is untouched, and the transcript gets a `pi-user-agents-detached` custom entry naming the session id so it can be resumed later. Both detach paths funnel through `closeRunning` and `removeCompleted`, which is where the announcement is made; squashing and session shutdown deliberately do not announce.
|
|
69
|
+
|
|
33
70
|
## Runtime sharing
|
|
34
71
|
|
|
35
72
|
Child agent sessions share the parent's `ModelRuntime` instance. This is critical because extension-registered providers (like `claude-bridge`) use a global `Symbol.for` dedup guard — they register once per process and skip subsequent loads. A child session with its own fresh runtime would never receive the provider, leaving it without auth for bridge-backed models.
|
package/PARSER_SPEC.md
CHANGED
|
@@ -10,7 +10,7 @@ from the declaration in `command-line.ts`.
|
|
|
10
10
|
```ts
|
|
11
11
|
type ParsedAgentCommand = {
|
|
12
12
|
isolate: boolean; // -i / --isolate
|
|
13
|
-
|
|
13
|
+
squash: boolean; // -s / --squash
|
|
14
14
|
forwardedArgs: string[]; // recognized pi option tokens (+values) in input order, e.g. ["--thinking","high"]
|
|
15
15
|
task: string; // the prose prompt (backslash escapes applied, quotes kept). Never empty.
|
|
16
16
|
warnings: string[]; // advisory messages (see §5). May be empty.
|
|
@@ -43,11 +43,11 @@ leading whitespace (unchanged from today).
|
|
|
43
43
|
|---|---|---|
|
|
44
44
|
| `-m`, `--model` | value | Added to `forwardedArgs` as canonical `--model <value>` |
|
|
45
45
|
| `-i`, `--isolate` | boolean | Consumed here; `isolate = true` |
|
|
46
|
-
| `-
|
|
46
|
+
| `-s`, `--squash` | boolean | Consumed here; `squash = true` |
|
|
47
47
|
|
|
48
48
|
`-m` and `--model` are one semantic option. Both require an exact live model ID, canonical
|
|
49
49
|
`provider/id` reference, or user-defined alias before resolving through Pi's `resolveCliModel()`
|
|
50
|
-
path. A preceding `--provider` scopes either spelling. `-
|
|
50
|
+
path. A preceding `--provider` scopes either spelling. `-s` is the extension's squash option.
|
|
51
51
|
A model option with no value and no remaining task ends in the normal
|
|
52
52
|
**usage error**.
|
|
53
53
|
|
package/README.md
CHANGED
|
@@ -1,10 +1,31 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
1
3
|
# pi-user-agents
|
|
2
4
|
|
|
3
|
-
> You deserve your own agents, too. It's only fair
|
|
5
|
+
> **You deserve your own agents, too. It's only fair.**
|
|
6
|
+
|
|
7
|
+
[](https://github.com/earendil-works/pi)
|
|
8
|
+
[](https://www.npmjs.com/package/@giladbarnea/pi-user-agents)
|
|
9
|
+
[](https://github.com/giladbarnea/pi-user-agents/actions/workflows/test.yml)
|
|
10
|
+
[](LICENSE)
|
|
11
|
+
[](https://www.typescriptlang.org/)
|
|
12
|
+
[](https://bun.sh/)
|
|
13
|
+
|
|
14
|
+
**No token tax. No derailed turns. No trace — unless you say so.**
|
|
4
15
|
|
|
5
16
|
Pi extensions give your agent subagents. This one gives **you** — the user — your own: background agents you dispatch mid-turn, watch live, steer, and whose results stay **out of the main agent's context** until you decide otherwise.
|
|
6
17
|
|
|
7
|
-
|
|
18
|
+
</div>
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## Why
|
|
23
|
+
|
|
24
|
+
You're watching your agent grind through phase 1, and you already know what phase 2 needs.
|
|
25
|
+
|
|
26
|
+
You have two options, and both are bad. Steer — and derail the work in progress with an errand. Wait — and carry the thought in your head for twenty more minutes.
|
|
27
|
+
|
|
28
|
+
Now there's a third:
|
|
8
29
|
|
|
9
30
|
```
|
|
10
31
|
/agent plan the next phase and write it to phase-2.md
|
|
@@ -12,6 +33,8 @@ The moment it's for: your main agent is deep in a tool-calling frenzy on phase 1
|
|
|
12
33
|
|
|
13
34
|
The instant you press Enter, a background agent forks off with a snapshot of the current conversation and gets to work — while your main agent keeps going, none the wiser.
|
|
14
35
|
|
|
36
|
+
**A second pair of hands with the same memory — and a separate token budget.**
|
|
37
|
+
|
|
15
38
|
## Install
|
|
16
39
|
|
|
17
40
|
```sh
|
|
@@ -29,10 +52,10 @@ Classic dispatches:
|
|
|
29
52
|
```
|
|
30
53
|
/agent write a handoff document for the work currently in progress
|
|
31
54
|
/agent -i -m flash summarize what PARSER_SPEC.md guarantees
|
|
32
|
-
/agent -
|
|
55
|
+
/agent -s --thinking high find the root cause of the flaky widget test
|
|
33
56
|
```
|
|
34
57
|
|
|
35
|
-
The first preserves the session's knowledge before the context window fills, without interrupting the main agent to do it. The second is a cheap, isolated errand. The third auto-
|
|
58
|
+
The first preserves the session's knowledge before the context window fills, without interrupting the main agent to do it. The second is a cheap, isolated errand. The third auto-squashes: its findings are delivered into the main conversation when it finishes.
|
|
36
59
|
|
|
37
60
|
### 2. Watch
|
|
38
61
|
|
|
@@ -46,7 +69,7 @@ Every agent gets a row in a widget under the editor: status, task, model, turn c
|
|
|
46
69
|
|
|
47
70
|
<br>
|
|
48
71
|
|
|
49
|
-
Press `←` or `↓` from the editor to focus the widget, pick an agent, press `Enter`, and its overlay opens: the full conversation, streaming live, following the tail as it grows.
|
|
72
|
+
Press `←` or `↓` from the editor to focus the widget, pick an agent, press `Enter`, and its overlay opens: the full conversation, streaming live, following the tail as it grows. Tool calls render as proper views — `read`, `edit`, `write`, `grep`, `find`, `ls`, and `bash` each get a dedicated format, everything else a readable generic one.
|
|
50
73
|
|
|
51
74
|
<a href="screenshots/overlay.png">
|
|
52
75
|
<img src="screenshots/overlay.png" width="100%" alt="The agent overlay: a rendered bash call, its output, a todo update, and the final response, over the dimmed main conversation">
|
|
@@ -54,34 +77,56 @@ Press `←` or `↓` from the editor to focus the widget, pick an agent, press `
|
|
|
54
77
|
|
|
55
78
|
<div align="center"><em>The overlay, over a main conversation that never paused.</em></div>
|
|
56
79
|
|
|
57
|
-
<br>
|
|
58
|
-
|
|
59
|
-
The context meter is the agent's own footer gauge in one cell: it fills `▁▂▃▄▅▆▇█` against that agent's model context window and shifts color through the same stages as Pi's footer (dim, then muted at 40%, warning at 65%, error at 85%).
|
|
60
|
-
|
|
61
80
|
### 3. Steer
|
|
62
81
|
|
|
63
82
|
A background agent is not fire-and-forget — it's a session you can talk to.
|
|
64
83
|
|
|
65
84
|
Press `Enter` in the overlay to open the steer composer. Mid-turn, your message queues in after the current tool batch, before the next model call — exactly like steering the main agent. After the turn completes, the same composer starts **another turn** on the same live session. Follow up as many times as you need; every completed turn posts its own result card.
|
|
66
85
|
|
|
67
|
-
`Ctrl+x` interrupts only the current turn — the agent goes idle and stays available for steering,
|
|
86
|
+
`Ctrl+x` interrupts only the current turn — the agent goes idle and stays available for steering, squashing, or detaching. In fact, nothing ends an agent except you: pressing `d` twice, squashing or rebasing it, or ending the Pi session.
|
|
87
|
+
|
|
88
|
+
### 4. Detach
|
|
89
|
+
|
|
90
|
+
When a row has served its purpose, press `d` twice: the agent stops, its row leaves the widget, and its session file is left untouched. The transcript records which one it was:
|
|
91
|
+
|
|
92
|
+
```
|
|
93
|
+
Detached session 0199c4f2-8b1a-7c3d-9e05-6a2f18d7b4ce
|
|
94
|
+
```
|
|
68
95
|
|
|
69
|
-
|
|
96
|
+
Pick it back up whenever you like — `/resume 0199c4f2`, or `pi -r` and choose it from the list. It resumes as an ordinary Pi session with its full history, its model, and its thinking level, and from there it is a normal agent you talk to directly.
|
|
97
|
+
|
|
98
|
+
### 5. Squash — or don't
|
|
70
99
|
|
|
71
100
|
This is what makes user agents different from subagents: **by default, the main agent never learns any of this happened.**
|
|
72
101
|
|
|
73
102
|
Each completed turn posts a result card into your transcript — metadata, a collapsed preview, expandable to the full Markdown response. The card is TUI-only: you read it, expand it, copy it, and the main agent's context is untouched. Ask an agent ten questions and your main agent's token budget doesn't move.
|
|
74
103
|
|
|
75
|
-
When a result does belong in the main conversation,
|
|
104
|
+
When a result does belong in the main conversation, squash it in:
|
|
105
|
+
|
|
106
|
+
- Dispatch with `-s`/`--squash` and the result is delivered automatically on completion.
|
|
107
|
+
- Or press `s` in the overlay of any completed agent, whenever you decide it earned its place.
|
|
76
108
|
|
|
77
|
-
|
|
78
|
-
- Or press `j` in the overlay of any completed agent, whenever you decide it earned its place.
|
|
109
|
+
A squash delivers a compact record, not a transcript dump: every message you sent the agent and the final answer to each — no thinking, no tool traffic. The record is rebuilt from the agent's full history at squash time, so early turns survive even after the agent compacts its own context. If the main agent is mid-turn, the record is steered in; if idle, it triggers a turn. A squashed agent retires; its overlay stays readable.
|
|
79
110
|
|
|
80
|
-
|
|
111
|
+
### 6. Or rebase — rewrite history
|
|
81
112
|
|
|
82
|
-
|
|
113
|
+
Squash has a raw sibling. Press `r` in the overlay and the agent's whole conversation — prompts, replies, tool calls and their results — is appended to the main session as ordinary messages, exactly as if you had prompted the main agent all along. The dispatch preamble is stripped, nothing is wrapped or summarized, and no trace remains that a background agent ever existed.
|
|
83
114
|
|
|
84
|
-
|
|
115
|
+
Everything the agent lived through rides along untouched. If it compacted its own context mid-run, the compaction rides along too, and the main conversation's context picks up exactly where the agent's left off. The transcript redraws with the conversation inline, plus one dim provenance line:
|
|
116
|
+
|
|
117
|
+
```
|
|
118
|
+
Rebased session 01a0534e-9fa7-7d6f-bd44-b85fba1f5e05 into this conversation (added 32 messages, ~135K tokens, 1 compaction event)
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Rebase is a fast-forward, in the git sense: the agent forked from the main conversation's tip, and its history can graft back only while that tip hasn't moved. Send the main agent anything after the dispatch and `r` disappears, leaving `s` — which always works — as the way in. Press `r` anyway and the footer tells you why not.
|
|
122
|
+
|
|
123
|
+
Delivering the rebase switches the session in place — same file, same session id, transcript redrawn — so `r` is withheld while any agent is mid-turn, and parked agents are detached first, each leaving its `Detached session …` line to `/resume` from. When siblings would be detached, the first `r` warns with the count and a second `r` confirms.
|
|
124
|
+
|
|
125
|
+
## The mechanics
|
|
126
|
+
|
|
127
|
+
### 🎛️ Per-dispatch configuration
|
|
128
|
+
|
|
129
|
+
Each agent takes its own configuration, using the same flags as the `pi` CLI:
|
|
85
130
|
|
|
86
131
|
```
|
|
87
132
|
/agent -m opus --thinking high design the caching layer
|
|
@@ -91,11 +136,21 @@ Each dispatch takes its own configuration, using the same flags as the `pi` CLI:
|
|
|
91
136
|
|
|
92
137
|
Options come first; everything after them is your task, verbatim. The full grammar lives in [PARSER_SPEC.md](PARSER_SPEC.md) — not that you'll need it.
|
|
93
138
|
|
|
94
|
-
|
|
139
|
+
### ✨ The fanciest autocomplete in the Pi universe
|
|
95
140
|
|
|
96
141
|
You'll rarely type any of this by hand. The moment you type `-`, a completion menu opens — no Tab needed. Anywhere the set of valid values is finite, the editor hands it to you: models, providers, thinking levels, the session's tools, even skill and prompt-template paths. You pick from what actually exists instead of typing and hoping.
|
|
97
142
|
|
|
98
|
-
And what you do type by hand is checked live, as you type. Valid options and values light up in your theme's syntax colors; anything that won't parse — a blocked option, a model that doesn't resolve, an option stranded after the task began — shows in the error color before you ever press Enter. A `/agent` line that looks right is right
|
|
143
|
+
And what you do type by hand is checked live, as you type. Valid options and values light up in your theme's syntax colors; anything that won't parse — a blocked option, a model that doesn't resolve, an option stranded after the task began — shows in the error color before you ever press Enter. **A `/agent` line that looks right is right.**
|
|
144
|
+
|
|
145
|
+
### 🧱 Agents are durable Pi sessions
|
|
146
|
+
|
|
147
|
+
Every dispatch writes a real session file, exactly like the one you're sitting in, so dispatched agents show up in `/resume` and `pi -r` alongside your own sessions.
|
|
148
|
+
|
|
149
|
+
Result cards persist across session reloads; the widget doesn't. After a reload you keep every card, and every agent's session is still on disk — but the steerable rows are gone. Reach them with `/resume`.
|
|
150
|
+
|
|
151
|
+
### 🌡️ A context meter in one cell
|
|
152
|
+
|
|
153
|
+
Each row carries the agent's own footer gauge: it fills `▁▂▃▄▅▆▇█` against that agent's model context window and shifts color through the same stages as Pi's footer — dim, then muted at 40%, warning at 65%, error at 85%. You see an agent approaching its limit before it becomes a problem.
|
|
99
154
|
|
|
100
155
|
## Reference
|
|
101
156
|
|
|
@@ -104,7 +159,7 @@ Everything goes through one command: `/agent [options] <task>`.
|
|
|
104
159
|
| Flag | Effect |
|
|
105
160
|
|---|---|
|
|
106
161
|
| `-i`, `--isolate` | Start without the conversation snapshot |
|
|
107
|
-
| `-
|
|
162
|
+
| `-s`, `--squash` | Deliver the result into the main context on completion |
|
|
108
163
|
| `-m MODEL` | Model for this agent (alias of Pi's `--model`) |
|
|
109
164
|
| *pi CLI options* | Forwarded to the agent — `--thinking`, `--tools`, `--system-prompt`, … |
|
|
110
165
|
|
|
@@ -113,23 +168,33 @@ Everything goes through one command: `/agent [options] <task>`.
|
|
|
113
168
|
| Editor | `←` / `↓` | Focus the agents widget |
|
|
114
169
|
| Widget | `↑` `↓` · `Enter` | Select an agent · open its overlay |
|
|
115
170
|
| Widget / overlay | `Ctrl+x` | Interrupt the current turn (agent stays alive) |
|
|
116
|
-
| Widget / overlay | `
|
|
171
|
+
| Widget / overlay | `d` `d` | Detach the agent, keeping its session (twice to confirm) |
|
|
117
172
|
| Widget / overlay | `Esc` | Back |
|
|
118
173
|
| Overlay | `Enter` | Steer mid-turn, or start another turn when idle |
|
|
119
|
-
| Overlay | `
|
|
174
|
+
| Overlay | `s` | Squash the conversation into the main context |
|
|
175
|
+
| Overlay | `r` | Rebase the raw conversation into the main context (fast-forward only) |
|
|
120
176
|
| Overlay | `c` | Copy the latest response |
|
|
121
177
|
| Overlay | scroll · `End` | Pause tail-following · resume it |
|
|
122
178
|
|
|
123
179
|
## Good to know
|
|
124
180
|
|
|
125
|
-
- Result cards persist across session reloads; live agent sessions don't. After a reload you keep every card, not the steerable sessions.
|
|
126
181
|
- Some accepted `pi` options have no effect on a background run (the session, approval, offline, and API-key families). They parse; they just don't do anything yet.
|
|
127
182
|
- `c` copies via `pbcopy`, so it's macOS-only for now.
|
|
128
183
|
- The overlay caps very large tool outputs and omits thinking entries.
|
|
129
184
|
|
|
185
|
+
## Roadmap
|
|
186
|
+
|
|
187
|
+
- [x] Agents stay alive until you end them — steer, interrupt, resume, across turns.
|
|
188
|
+
- [x] Squash: a compact record of the exchange, delivered into the main context.
|
|
189
|
+
- [x] Rebase: the raw conversation grafted onto the main session — fast-forward only, compaction-faithful.
|
|
190
|
+
- [x] Live syntax coloring and eager autocomplete for the `/agent` line.
|
|
191
|
+
- [ ] Render an agent's compaction event in the overlay.
|
|
192
|
+
- [ ] Wire the accepted-but-inert `pi` options.
|
|
193
|
+
- [ ] Clipboard support beyond macOS.
|
|
194
|
+
|
|
130
195
|
## Under the hood
|
|
131
196
|
|
|
132
|
-
Design notes — result delivery through the Pi SDK, the
|
|
197
|
+
Design notes — result delivery through the Pi SDK, the squashed-message format, the rebase mechanism, runtime sharing, model resolution, and editor internals — live in [INTERNALS.md](INTERNALS.md). The complete command grammar lives in [PARSER_SPEC.md](PARSER_SPEC.md).
|
|
133
198
|
|
|
134
199
|
---
|
|
135
200
|
|
package/agent-viewer.ts
CHANGED
|
@@ -14,7 +14,9 @@ import {
|
|
|
14
14
|
import { renderAgentContextMeter } from "./context-meter.js";
|
|
15
15
|
import type { AgentMessage, CompletedAgent, RunningAgent, Theme } from "./shared.js";
|
|
16
16
|
import {
|
|
17
|
+
CONFIRMATION_WINDOW_MS,
|
|
17
18
|
errorMessage,
|
|
19
|
+
TimedConfirmation,
|
|
18
20
|
formatMs,
|
|
19
21
|
formatToolUses,
|
|
20
22
|
formatTurns,
|
|
@@ -32,7 +34,7 @@ import {
|
|
|
32
34
|
} from "./tool-render/index.js";
|
|
33
35
|
|
|
34
36
|
export type ViewableAgent = RunningAgent | CompletedAgent;
|
|
35
|
-
export type AgentViewerAction = "hide" | "
|
|
37
|
+
export type AgentViewerAction = "hide" | "detach";
|
|
36
38
|
|
|
37
39
|
export type ActivityDescription = {
|
|
38
40
|
text: string;
|
|
@@ -141,7 +143,7 @@ function truncateMiddle(
|
|
|
141
143
|
|
|
142
144
|
const VIEWER_CHROME_LINES = 6;
|
|
143
145
|
const VIEWER_MIN_VIEWPORT = 3;
|
|
144
|
-
const VIEWER_HEIGHT_PCT =
|
|
146
|
+
const VIEWER_HEIGHT_PCT = 85;
|
|
145
147
|
|
|
146
148
|
/** Focus-capturing overlay that shows an agent's live or finished response. */
|
|
147
149
|
export class AgentViewer implements Component {
|
|
@@ -149,7 +151,12 @@ export class AgentViewer implements Component {
|
|
|
149
151
|
private autoScroll = true;
|
|
150
152
|
private lastWidth = 0;
|
|
151
153
|
private justCopied = false;
|
|
152
|
-
private
|
|
154
|
+
private rebaseWarning: string | undefined;
|
|
155
|
+
private rebaseWarningTimer: ReturnType<typeof setTimeout> | undefined;
|
|
156
|
+
/** One confirmation slot for both destructive overlay actions. */
|
|
157
|
+
private readonly confirmation = new TimedConfirmation<"detach" | "rebase">(() =>
|
|
158
|
+
this.tui.requestRender(),
|
|
159
|
+
);
|
|
153
160
|
private composer: Input | undefined;
|
|
154
161
|
/** Rich tool rendering is far too costly to repeat on every 100 ms widget tick. */
|
|
155
162
|
private renderedContent: { key: string; lines: string[] } | undefined;
|
|
@@ -159,29 +166,33 @@ export class AgentViewer implements Component {
|
|
|
159
166
|
private readonly agent: ViewableAgent,
|
|
160
167
|
private readonly theme: Theme,
|
|
161
168
|
private readonly done: (result: AgentViewerAction) => void,
|
|
162
|
-
private readonly
|
|
163
|
-
private readonly
|
|
169
|
+
private readonly canSquashMainContext: () => boolean,
|
|
170
|
+
private readonly squashMainContext: () => void,
|
|
171
|
+
private readonly canRebaseMainContext: () => boolean,
|
|
172
|
+
private readonly rebaseMainContext: () => void,
|
|
173
|
+
private readonly rebaseBlockReason: () => string | undefined,
|
|
174
|
+
private readonly rebaseDetachCount: () => number,
|
|
164
175
|
private readonly interrupt: () => void,
|
|
165
176
|
) {}
|
|
166
177
|
|
|
167
178
|
handleInput(data: string): void {
|
|
168
179
|
if (matchesKey(data, "ctrl+x") && isLiveAgent(this.agent)) {
|
|
169
|
-
this.
|
|
180
|
+
this.confirmation.cancel();
|
|
170
181
|
this.interrupt();
|
|
171
182
|
this.tui.requestRender();
|
|
172
183
|
return;
|
|
173
184
|
}
|
|
174
185
|
if (this.composer) {
|
|
175
|
-
this.
|
|
186
|
+
this.confirmation.cancel();
|
|
176
187
|
this.composer.handleInput(data);
|
|
177
188
|
this.tui.requestRender();
|
|
178
189
|
return;
|
|
179
190
|
}
|
|
180
|
-
if (matchesKey(data, "
|
|
181
|
-
this.
|
|
191
|
+
if (matchesKey(data, "d")) {
|
|
192
|
+
this.requestDetach();
|
|
182
193
|
return;
|
|
183
194
|
}
|
|
184
|
-
this.
|
|
195
|
+
if (!matchesKey(data, "r")) this.confirmation.cancel();
|
|
185
196
|
if (matchesKey(data, "escape") || matchesKey(data, "q")) {
|
|
186
197
|
this.done("hide");
|
|
187
198
|
return;
|
|
@@ -194,11 +205,29 @@ export class AgentViewer implements Component {
|
|
|
194
205
|
this.copyResponse();
|
|
195
206
|
return;
|
|
196
207
|
}
|
|
197
|
-
if (matchesKey(data, "
|
|
198
|
-
this.
|
|
208
|
+
if (matchesKey(data, "s") && this.canSquashMainContext()) {
|
|
209
|
+
this.squashMainContext();
|
|
199
210
|
this.tui.requestRender();
|
|
200
211
|
return;
|
|
201
212
|
}
|
|
213
|
+
if (matchesKey(data, "r")) {
|
|
214
|
+
if (this.canRebaseMainContext()) {
|
|
215
|
+
if (this.rebaseDetachCount() > 0 && !this.confirmation.press("rebase")) {
|
|
216
|
+
this.tui.requestRender();
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
// Close the overlay: the payoff is the rebased conversation now sitting in the main transcript.
|
|
220
|
+
this.rebaseMainContext();
|
|
221
|
+
this.done("hide");
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
this.confirmation.cancel();
|
|
225
|
+
const reason = this.rebaseBlockReason();
|
|
226
|
+
if (reason) {
|
|
227
|
+
this.showRebaseWarning(`Can't rebase: ${reason}`);
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
202
231
|
const viewport = this.viewportHeight();
|
|
203
232
|
const maxScroll = Math.max(
|
|
204
233
|
0,
|
|
@@ -206,7 +235,7 @@ export class AgentViewer implements Component {
|
|
|
206
235
|
);
|
|
207
236
|
if (matchesKey(data, "up") || matchesKey(data, "k"))
|
|
208
237
|
this.scrollOffset = Math.max(0, this.scrollOffset - 1);
|
|
209
|
-
else if (matchesKey(data, "down"))
|
|
238
|
+
else if (matchesKey(data, "down") || matchesKey(data, "j"))
|
|
210
239
|
this.scrollOffset = Math.min(maxScroll, this.scrollOffset + 1);
|
|
211
240
|
else if (matchesKey(data, "pageUp") || matchesKey(data, "shift+up"))
|
|
212
241
|
this.scrollOffset = Math.max(0, this.scrollOffset - viewport);
|
|
@@ -253,6 +282,7 @@ export class AgentViewer implements Component {
|
|
|
253
282
|
renderAgentContextMeter(this.agent, th),
|
|
254
283
|
th.fg("dim", status),
|
|
255
284
|
...stats.map((stat) => th.fg("dim", stat)),
|
|
285
|
+
th.fg("dim", this.agent.sessionId),
|
|
256
286
|
]
|
|
257
287
|
.filter(Boolean)
|
|
258
288
|
.join(` ${th.fg("dim", "·")} `);
|
|
@@ -289,6 +319,18 @@ export class AgentViewer implements Component {
|
|
|
289
319
|
composeHint,
|
|
290
320
|
),
|
|
291
321
|
);
|
|
322
|
+
} else if (this.confirmation.isArmedOn("rebase")) {
|
|
323
|
+
const detachCount = this.rebaseDetachCount();
|
|
324
|
+
lines.push(
|
|
325
|
+
row(
|
|
326
|
+
th.fg(
|
|
327
|
+
"warning",
|
|
328
|
+
`⚠ Rebase will detach ${detachCount} other agent session${detachCount === 1 ? "" : "s"}. r again to confirm`,
|
|
329
|
+
),
|
|
330
|
+
),
|
|
331
|
+
);
|
|
332
|
+
} else if (this.rebaseWarning) {
|
|
333
|
+
lines.push(row(th.fg("warning", `⚠ ${this.rebaseWarning}`)));
|
|
292
334
|
} else {
|
|
293
335
|
const scrollPct =
|
|
294
336
|
content.length <= viewport
|
|
@@ -305,13 +347,14 @@ export class AgentViewer implements Component {
|
|
|
305
347
|
this.justCopied ? th.fg("success", "✓ copied") : th.fg("dim", "c copy"),
|
|
306
348
|
th.fg("dim", "↑↓ scroll"),
|
|
307
349
|
th.fg("dim", "PgUp/PgDn"),
|
|
308
|
-
this.
|
|
350
|
+
this.canSquashMainContext() ? th.fg("dim", "s squash") : "",
|
|
351
|
+
this.canRebaseMainContext() ? th.fg("dim", "r rebase") : "",
|
|
309
352
|
isLiveAgent(this.agent) ? th.fg("dim", "Ctrl+x interrupt") : "",
|
|
310
353
|
mainContext ? th.fg("dim", mainContext) : "",
|
|
311
354
|
isLiveAgent(this.agent) ? th.fg("dim", "Esc hide") : "",
|
|
312
355
|
th.fg(
|
|
313
|
-
this.
|
|
314
|
-
this.
|
|
356
|
+
this.confirmation.isArmedOn("detach") ? "error" : "dim",
|
|
357
|
+
this.confirmation.isArmedOn("detach") ? "d again to confirm" : "d detach",
|
|
315
358
|
),
|
|
316
359
|
].filter(Boolean),
|
|
317
360
|
);
|
|
@@ -325,12 +368,22 @@ export class AgentViewer implements Component {
|
|
|
325
368
|
|
|
326
369
|
dispose(): void {}
|
|
327
370
|
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
371
|
+
/** Surface why r did nothing, in the footer, for a few seconds. Memory-only, like `✓ copied`. */
|
|
372
|
+
private showRebaseWarning(warning: string): void {
|
|
373
|
+
this.rebaseWarning = warning;
|
|
374
|
+
if (this.rebaseWarningTimer) clearTimeout(this.rebaseWarningTimer);
|
|
375
|
+
this.rebaseWarningTimer = setTimeout(() => {
|
|
376
|
+
this.rebaseWarning = undefined;
|
|
377
|
+
this.tui.requestRender();
|
|
378
|
+
}, CONFIRMATION_WINDOW_MS);
|
|
379
|
+
this.tui.requestRender();
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
private requestDetach(): void {
|
|
383
|
+
if (this.confirmation.press("detach")) {
|
|
384
|
+
this.done("detach");
|
|
331
385
|
return;
|
|
332
386
|
}
|
|
333
|
-
this.confirmingDispose = true;
|
|
334
387
|
this.tui.requestRender();
|
|
335
388
|
}
|
|
336
389
|
|
package/command-line.ts
CHANGED
|
@@ -57,7 +57,7 @@ export type AgentSemanticToken = {
|
|
|
57
57
|
|
|
58
58
|
export type AgentArgumentScan = {
|
|
59
59
|
isolate: boolean;
|
|
60
|
-
|
|
60
|
+
squash: boolean;
|
|
61
61
|
forwardedArgs: string[];
|
|
62
62
|
/** Ordered, non-overlapping semantic tokens over the argument text. Prose stays untokenized. */
|
|
63
63
|
tokens: AgentSemanticToken[];
|
|
@@ -115,12 +115,12 @@ export const AGENT_OPTIONS: readonly AgentOptionDefinition[] = [
|
|
|
115
115
|
description: "Start without parent-session context",
|
|
116
116
|
},
|
|
117
117
|
{
|
|
118
|
-
semanticId: "
|
|
119
|
-
names: ["-
|
|
118
|
+
semanticId: "squash",
|
|
119
|
+
names: ["-s", "--squash"],
|
|
120
120
|
role: "extension",
|
|
121
121
|
arity: "boolean",
|
|
122
122
|
autocomplete: true,
|
|
123
|
-
description: "
|
|
123
|
+
description: "Squash the completed result into the main agent context",
|
|
124
124
|
},
|
|
125
125
|
{
|
|
126
126
|
semanticId: "provider",
|
|
@@ -360,7 +360,7 @@ export function scanAgentArguments(
|
|
|
360
360
|
const tokens: AgentSemanticToken[] = [];
|
|
361
361
|
const forwardedArgs: string[] = [];
|
|
362
362
|
let isolate = false;
|
|
363
|
-
let
|
|
363
|
+
let squash = false;
|
|
364
364
|
let blocked: AgentSemanticToken | undefined;
|
|
365
365
|
let consumedOption = false;
|
|
366
366
|
let position = 0;
|
|
@@ -393,8 +393,8 @@ export function scanAgentArguments(
|
|
|
393
393
|
}
|
|
394
394
|
} else if (option.semanticId === "isolate") {
|
|
395
395
|
isolate = true;
|
|
396
|
-
} else if (option.semanticId === "
|
|
397
|
-
|
|
396
|
+
} else if (option.semanticId === "squash") {
|
|
397
|
+
squash = true;
|
|
398
398
|
} else if (option.arity === "value") {
|
|
399
399
|
forwardedArgs.push(option.forwardName ?? token);
|
|
400
400
|
const read = readValueSpan(args, position);
|
|
@@ -427,7 +427,7 @@ export function scanAgentArguments(
|
|
|
427
427
|
}
|
|
428
428
|
const scan: AgentArgumentScan = {
|
|
429
429
|
isolate,
|
|
430
|
-
|
|
430
|
+
squash,
|
|
431
431
|
forwardedArgs,
|
|
432
432
|
tokens,
|
|
433
433
|
proseStart,
|
|
@@ -487,11 +487,11 @@ export function parseAgentCommand(args: string, command: AgentCommandName): Pars
|
|
|
487
487
|
}
|
|
488
488
|
if (!scan.prose.trim())
|
|
489
489
|
throw new Error(
|
|
490
|
-
`Usage: /${command} [pi options] [-m MODELNAME] [-i|--isolate] [-
|
|
490
|
+
`Usage: /${command} [pi options] [-m MODELNAME] [-i|--isolate] [-s|--squash] "<task>"`,
|
|
491
491
|
);
|
|
492
492
|
return {
|
|
493
493
|
isolate: scan.isolate,
|
|
494
|
-
|
|
494
|
+
squash: scan.squash,
|
|
495
495
|
forwardedArgs: scan.forwardedArgs,
|
|
496
496
|
task: scan.task,
|
|
497
497
|
warnings: scan.warnings,
|
package/index.ts
CHANGED
|
@@ -3,16 +3,27 @@ import type {
|
|
|
3
3
|
MessageEndEventResult,
|
|
4
4
|
} from "@earendil-works/pi-coding-agent";
|
|
5
5
|
import { registerAgentAutocomplete } from "./autocomplete.js";
|
|
6
|
-
import { handleAgentCommand } from "./runner.js";
|
|
7
|
-
import type {
|
|
8
|
-
|
|
6
|
+
import { createRebaseDelivery, handleAgentCommand } from "./runner.js";
|
|
7
|
+
import type {
|
|
8
|
+
AgentCommandDetails,
|
|
9
|
+
DetachedEntryData,
|
|
10
|
+
ExtensionAPI,
|
|
11
|
+
ExtensionCommandContext,
|
|
12
|
+
RunningAgent,
|
|
13
|
+
} from "./shared.js";
|
|
14
|
+
import { DETACHED_ENTRY_TYPE, MESSAGE_TYPE } from "./shared.js";
|
|
9
15
|
import { registerUserAgentRenderer } from "./transcript.js";
|
|
10
16
|
import { UserAgentWidget } from "./widget.js";
|
|
11
17
|
|
|
12
18
|
export default function userAgent(pi: ExtensionAPI): void {
|
|
13
19
|
const runningAgents = new Set<RunningAgent>();
|
|
14
|
-
|
|
15
|
-
|
|
20
|
+
// The session every agent was dispatched from; rebase fast-forwards onto it.
|
|
21
|
+
let mainSessionContext: ExtensionCommandContext | undefined;
|
|
22
|
+
const widget = new UserAgentWidget(
|
|
23
|
+
runningAgents,
|
|
24
|
+
(message) => pi.sendMessage(message, { triggerTurn: true }),
|
|
25
|
+
(sessionId) => pi.appendEntry<DetachedEntryData>(DETACHED_ENTRY_TYPE, { sessionId }),
|
|
26
|
+
createRebaseDelivery(pi, () => mainSessionContext),
|
|
16
27
|
);
|
|
17
28
|
let shuttingDown = false;
|
|
18
29
|
let nextAgentNumber = 0;
|
|
@@ -20,12 +31,13 @@ export default function userAgent(pi: ExtensionAPI): void {
|
|
|
20
31
|
registerUserAgentRenderer(pi);
|
|
21
32
|
registerAgentAutocomplete(pi);
|
|
22
33
|
|
|
23
|
-
pi.on("message_end", (event) =>
|
|
34
|
+
pi.on("message_end", (event) => confirmMainContextSquash(event, widget));
|
|
24
35
|
|
|
25
36
|
pi.registerCommand("agent", {
|
|
26
37
|
description:
|
|
27
38
|
"Run a background SDK agent with current session context; forwards pi CLI options like --thinking (-i/--isolate for none)",
|
|
28
39
|
handler: async (args, ctx) => {
|
|
40
|
+
mainSessionContext = ctx;
|
|
29
41
|
await handleAgentCommand(
|
|
30
42
|
pi,
|
|
31
43
|
runningAgents,
|
|
@@ -56,19 +68,19 @@ export default function userAgent(pi: ExtensionAPI): void {
|
|
|
56
68
|
});
|
|
57
69
|
}
|
|
58
70
|
|
|
59
|
-
export function
|
|
71
|
+
export function confirmMainContextSquash(
|
|
60
72
|
event: MessageEndEvent,
|
|
61
73
|
widget: UserAgentWidget,
|
|
62
74
|
): MessageEndEventResult | undefined {
|
|
63
75
|
if (event.message.role !== "custom" || event.message.customType !== MESSAGE_TYPE) return;
|
|
64
76
|
const details = event.message.details as AgentCommandDetails | undefined;
|
|
65
|
-
if (!details?.agentId || details.mainContextState !== "will-
|
|
66
|
-
widget.
|
|
67
|
-
details.mainContextState = "
|
|
77
|
+
if (!details?.agentId || details.mainContextState !== "will-squash") return;
|
|
78
|
+
widget.confirmMainContextSquash(details.agentId);
|
|
79
|
+
details.mainContextState = "squashed";
|
|
68
80
|
return {
|
|
69
81
|
message: {
|
|
70
82
|
...event.message,
|
|
71
|
-
details: { ...details, mainContextState: "
|
|
83
|
+
details: { ...details, mainContextState: "squashed" },
|
|
72
84
|
},
|
|
73
85
|
};
|
|
74
86
|
}
|