@giladbarnea/pi-user-agents 0.0.4 → 0.0.6

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 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 `-j` flag: the default posts the result card via `appendEntry` (main agent structurally oblivious), and `-j` posts a visible custom message with `{ triggerTurn: true }` (steered mid-turn or answered immediately). Both channels share one card renderer in `transcript.ts`.
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 `-j` delivery and late `j join` 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 `-j`, the context message stays hidden at join time because the TUI-only result card is already visible.
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
- ## Joined message format
23
+ ## Squashed message format
24
24
 
25
- The joined text starts with this sentence:
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,26 @@ 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
+
33
53
  ## Child session persistence
34
54
 
35
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.
@@ -37,7 +57,7 @@ Every dispatched agent gets a persisted `SessionManager.create(cwd, undefined, {
37
57
  Two ordering constraints follow from `createAgentSession`:
38
58
 
39
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.
40
- 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. `persistInheritedMessages` appends it entry by entry, then the agent state is set to the same list.
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.
41
61
 
42
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.
43
63
 
@@ -45,7 +65,17 @@ The file itself appears on the child's first assistant message (`_persist` defer
45
65
 
46
66
  ## Detaching
47
67
 
48
- `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; joining and session shutdown deliberately do not announce.
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
+
70
+ ## Attaching
71
+
72
+ `/agent-attach <session-id>` reverses a detach. The id (or a unique prefix) resolves in one pass over the whole universe of candidates: the widget's rows (live and completed) united with file names in pi's default session directory for the cwd — the directory every dispatch creates children in. File names, not `SessionManager.list()`, because list() reads every session file whole, gigabytes in a mature project; the union, because a prefix unique among rows could otherwise mask an ambiguity with a detached file, and a live row whose file is not flushed yet must still resolve. A resolved id that is already a row short-circuits to the "View agent?" confirmation and nothing else — the idempotent case. The authority for "is a child of this session" is the child file's header: its `parentSession` path must equal the main session's file. That is the same field `/fork` writes, so a fork of the main session attaches too.
73
+
74
+ The session file does not persist the dispatch options, so every dispatch writes a `pi-user-agents-dispatch` custom entry into the child — the forwarded pi tokens, the plain task, and the isolate flag. At attach the record replays through the same projections dispatch used (`buildChildResourceLoaderOptions`, `resolveToolOptions`), so `--tools`, `--no-extensions`, `--system-prompt` and friends survive the round trip instead of silently widening back to pi defaults. The model and thinking level deliberately come from the file, not the record: reattachment is pi's own resume path — `SessionManager.open` plus `createAgentSessionFromServices` on a non-empty manager takes `createAgentSession`'s has-existing-session branch — and the file reflects any later changes. A record-less child (dispatched before the record existed, or `/fork`-made) still attaches, with pi's default tools and resources.
75
+
76
+ The dispatch preamble — built at the dispatch call site, sent verbatim by `runChildTurns` — doubles as the boundary marker: `splitAtDispatchBoundary` finds the first user message carrying it and recovers the child's own conversation for the overlay and the inherited base whose fingerprint re-arms the rebase fast-forward. The fingerprint compares content, not file bookkeeping: rebuilding custom, compaction, and branch summaries from a session file re-stamps their timestamps (and a branch summary's `fromId`), so `conversationFingerprint` drops those fields or a round-tripped base would never equal main's live context. Without a recognizable boundary (a fork child, or a child whose compaction summarized it away) the whole context attaches and the base is a sentinel that never matches, so `r` stays withheld.
77
+
78
+ The attached agent parks idle first (`runAttachedTurns` waits for an instruction before running the shared turn loop), and its latest persisted response seeds `pendingSquashMessage`, so steering, `s`, and `r` work immediately without a new turn. A successful attach leaves an `Attached session …` breadcrumb — the detach line's counterpart — and both success states end with a "View agent?" confirmation that opens the overlay.
49
79
 
50
80
  ## Runtime sharing
51
81
 
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
- context: boolean; // -j / --join
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
- | `-j`, `--join` | boolean | Consumed here; `context = true` |
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. `-j` is the extension's join option.
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
+ [![pi extension](https://img.shields.io/badge/pi-extension-blueviolet)](https://github.com/earendil-works/pi)
8
+ [![npm version](https://img.shields.io/npm/v/%40giladbarnea%2Fpi-user-agents?logo=npm)](https://www.npmjs.com/package/@giladbarnea/pi-user-agents)
9
+ [![tests](https://img.shields.io/github/actions/workflow/status/giladbarnea/pi-user-agents/test.yml?branch=main&label=tests&logo=github)](https://github.com/giladbarnea/pi-user-agents/actions/workflows/test.yml)
10
+ [![license](https://img.shields.io/github/license/giladbarnea/pi-user-agents)](LICENSE)
11
+ [![TypeScript](https://img.shields.io/badge/TypeScript-3178C6?logo=typescript&logoColor=white)](https://www.typescriptlang.org/)
12
+ [![tested with Bun](https://img.shields.io/badge/tested_with-Bun-14151A?logo=bun&logoColor=white)](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
- The moment it's for: your main agent is deep in a tool-calling frenzy on phase 1, and you already know what phase 2 needs.
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 -j --thinking high find the root cause of the flaky widget test
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-joins: its findings are delivered into the main conversation when it finishes.
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. Scroll up to pause following; `End` resumes it. 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.
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,21 +77,17 @@ 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, joining, or detaching. In fact, nothing ends an agent except you: pressing `d` twice, joining it, or ending the Pi session.
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.
68
87
 
69
88
  ### 4. Detach
70
89
 
71
- Every dispatched agent gets its own Pi session on disk, exactly like the one you're sitting in. 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:
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:
72
91
 
73
92
  ```
74
93
  Detached session 0199c4f2-8b1a-7c3d-9e05-6a2f18d7b4ce
@@ -76,22 +95,40 @@ Detached session 0199c4f2-8b1a-7c3d-9e05-6a2f18d7b4ce
76
95
 
77
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.
78
97
 
79
- ### 5. Join — or don't
98
+ Or take it back as a background agent instead: `/agent-attach 0199c4f2` puts the session back in the widget — parked idle, restored as dispatched down to its `--tools` and the rest of its options — and offers to open it. A unique id prefix is enough, only this conversation's own agents qualify, and the transcript records the return: `Attached session 0199c4f2-…`.
99
+
100
+ ### 5. Squash — or don't
80
101
 
81
102
  This is what makes user agents different from subagents: **by default, the main agent never learns any of this happened.**
82
103
 
83
104
  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.
84
105
 
85
- When a result does belong in the main conversation, join it:
106
+ When a result does belong in the main conversation, squash it in:
107
+
108
+ - Dispatch with `-s`/`--squash` and the result is delivered automatically on completion.
109
+ - Or press `s` in the overlay of any completed agent, whenever you decide it earned its place.
86
110
 
87
- - Dispatch with `-j`/`--join` and the result is delivered automatically on completion.
88
- - Or press `j` in the overlay of any completed agent, whenever you decide it earned its place.
111
+ 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.
89
112
 
90
- Joining 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 join 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 joined agent retires; its overlay stays readable.
113
+ ### 6. Or rebase — rewrite history
91
114
 
92
- ## Per-agent configuration
115
+ 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.
93
116
 
94
- Each dispatch takes its own configuration, using the same flags as the `pi` CLI:
117
+ 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:
118
+
119
+ ```
120
+ Rebased session 01a0534e-9fa7-7d6f-bd44-b85fba1f5e05 into this conversation (added 32 messages, ~135K tokens, 1 compaction event)
121
+ ```
122
+
123
+ 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.
124
+
125
+ 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.
126
+
127
+ ## The mechanics
128
+
129
+ ### 🎛️ Per-dispatch configuration
130
+
131
+ Each agent takes its own configuration, using the same flags as the `pi` CLI:
95
132
 
96
133
  ```
97
134
  /agent -m opus --thinking high design the caching layer
@@ -101,20 +138,30 @@ Each dispatch takes its own configuration, using the same flags as the `pi` CLI:
101
138
 
102
139
  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.
103
140
 
104
- ## The fanciest autocomplete in the Pi universe
141
+ ### ✨ The fanciest autocomplete in the Pi universe
105
142
 
106
143
  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.
107
144
 
108
- 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.
145
+ 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.**
146
+
147
+ ### 🧱 Agents are durable Pi sessions
148
+
149
+ 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.
150
+
151
+ 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`.
152
+
153
+ ### 🌡️ A context meter in one cell
154
+
155
+ 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.
109
156
 
110
157
  ## Reference
111
158
 
112
- Everything goes through one command: `/agent [options] <task>`.
159
+ Everything goes through one command: `/agent [options] <task>`. Its one companion, `/agent-attach <session-id>`, brings a detached session back into the widget.
113
160
 
114
161
  | Flag | Effect |
115
162
  |---|---|
116
163
  | `-i`, `--isolate` | Start without the conversation snapshot |
117
- | `-j`, `--join` | Deliver the result into the main context on completion |
164
+ | `-s`, `--squash` | Deliver the result into the main context on completion |
118
165
  | `-m MODEL` | Model for this agent (alias of Pi's `--model`) |
119
166
  | *pi CLI options* | Forwarded to the agent — `--thinking`, `--tools`, `--system-prompt`, … |
120
167
 
@@ -126,21 +173,31 @@ Everything goes through one command: `/agent [options] <task>`.
126
173
  | Widget / overlay | `d` `d` | Detach the agent, keeping its session (twice to confirm) |
127
174
  | Widget / overlay | `Esc` | Back |
128
175
  | Overlay | `Enter` | Steer mid-turn, or start another turn when idle |
129
- | Overlay | `j` | Join the conversation into the main context |
176
+ | Overlay | `s` | Squash the conversation into the main context |
177
+ | Overlay | `r` | Rebase the raw conversation into the main context (fast-forward only) |
130
178
  | Overlay | `c` | Copy the latest response |
131
179
  | Overlay | scroll · `End` | Pause tail-following · resume it |
132
180
 
133
181
  ## Good to know
134
182
 
135
- - Every dispatch writes a real session file, so dispatched agents show up in `/resume` and `pi -r` alongside your own sessions. An agent that never answered leaves no file.
136
- - 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`.
137
183
  - 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.
138
184
  - `c` copies via `pbcopy`, so it's macOS-only for now.
139
185
  - The overlay caps very large tool outputs and omits thinking entries.
140
186
 
187
+ ## Roadmap
188
+
189
+ - [x] Agents stay alive until you end them — steer, interrupt, resume, across turns.
190
+ - [x] Squash: a compact record of the exchange, delivered into the main context.
191
+ - [x] Rebase: the raw conversation grafted onto the main session — fast-forward only, compaction-faithful.
192
+ - [x] Attach: a detached session returns to the widget, restored as dispatched.
193
+ - [x] Live syntax coloring and eager autocomplete for the `/agent` line.
194
+ - [ ] Render an agent's compaction event in the overlay.
195
+ - [ ] Wire the accepted-but-inert `pi` options.
196
+ - [ ] Clipboard support beyond macOS.
197
+
141
198
  ## Under the hood
142
199
 
143
- Design notes — result delivery through the Pi SDK, the joined-message format, 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).
200
+ Design notes — result delivery through the Pi SDK, the squashed-message format, the rebase and attach mechanisms, 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).
144
201
 
145
202
  ---
146
203
 
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,
@@ -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 confirmingDetach = false;
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,20 +166,24 @@ 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 canJoinMainContext: () => boolean,
163
- private readonly joinMainContext: () => void,
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.confirmingDetach = false;
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.confirmingDetach = false;
186
+ this.confirmation.cancel();
176
187
  this.composer.handleInput(data);
177
188
  this.tui.requestRender();
178
189
  return;
@@ -181,7 +192,7 @@ export class AgentViewer implements Component {
181
192
  this.requestDetach();
182
193
  return;
183
194
  }
184
- this.confirmingDetach = false;
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, "j") && this.canJoinMainContext()) {
198
- this.joinMainContext();
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);
@@ -290,6 +319,18 @@ export class AgentViewer implements Component {
290
319
  composeHint,
291
320
  ),
292
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}`)));
293
334
  } else {
294
335
  const scrollPct =
295
336
  content.length <= viewport
@@ -306,13 +347,14 @@ export class AgentViewer implements Component {
306
347
  this.justCopied ? th.fg("success", "✓ copied") : th.fg("dim", "c copy"),
307
348
  th.fg("dim", "↑↓ scroll"),
308
349
  th.fg("dim", "PgUp/PgDn"),
309
- this.canJoinMainContext() ? th.fg("dim", "j join") : "",
350
+ this.canSquashMainContext() ? th.fg("dim", "s squash") : "",
351
+ this.canRebaseMainContext() ? th.fg("dim", "r rebase") : "",
310
352
  isLiveAgent(this.agent) ? th.fg("dim", "Ctrl+x interrupt") : "",
311
353
  mainContext ? th.fg("dim", mainContext) : "",
312
354
  isLiveAgent(this.agent) ? th.fg("dim", "Esc hide") : "",
313
355
  th.fg(
314
- this.confirmingDetach ? "error" : "dim",
315
- this.confirmingDetach ? "d again to confirm" : "d detach",
356
+ this.confirmation.isArmedOn("detach") ? "error" : "dim",
357
+ this.confirmation.isArmedOn("detach") ? "d again to confirm" : "d detach",
316
358
  ),
317
359
  ].filter(Boolean),
318
360
  );
@@ -326,12 +368,22 @@ export class AgentViewer implements Component {
326
368
 
327
369
  dispose(): void {}
328
370
 
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
+
329
382
  private requestDetach(): void {
330
- if (this.confirmingDetach) {
383
+ if (this.confirmation.press("detach")) {
331
384
  this.done("detach");
332
385
  return;
333
386
  }
334
- this.confirmingDetach = true;
335
387
  this.tui.requestRender();
336
388
  }
337
389