@giladbarnea/pi-user-agents 0.0.4 → 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 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,7 @@ 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.
49
69
 
50
70
  ## Runtime sharing
51
71
 
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,38 @@ 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
+ ### 5. Squash — or don't
80
99
 
81
100
  This is what makes user agents different from subagents: **by default, the main agent never learns any of this happened.**
82
101
 
83
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.
84
103
 
85
- When a result does belong in the main conversation, join it:
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.
86
108
 
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.
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.
89
110
 
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.
111
+ ### 6. Or rebase — rewrite history
91
112
 
92
- ## Per-agent configuration
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.
93
114
 
94
- Each dispatch takes its own configuration, using the same flags as the `pi` CLI:
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:
95
130
 
96
131
  ```
97
132
  /agent -m opus --thinking high design the caching layer
@@ -101,11 +136,21 @@ Each dispatch takes its own configuration, using the same flags as the `pi` CLI:
101
136
 
102
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.
103
138
 
104
- ## The fanciest autocomplete in the Pi universe
139
+ ### ✨ The fanciest autocomplete in the Pi universe
105
140
 
106
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.
107
142
 
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.
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.
109
154
 
110
155
  ## Reference
111
156
 
@@ -114,7 +159,7 @@ Everything goes through one command: `/agent [options] <task>`.
114
159
  | Flag | Effect |
115
160
  |---|---|
116
161
  | `-i`, `--isolate` | Start without the conversation snapshot |
117
- | `-j`, `--join` | Deliver the result into the main context on completion |
162
+ | `-s`, `--squash` | Deliver the result into the main context on completion |
118
163
  | `-m MODEL` | Model for this agent (alias of Pi's `--model`) |
119
164
  | *pi CLI options* | Forwarded to the agent — `--thinking`, `--tools`, `--system-prompt`, … |
120
165
 
@@ -126,21 +171,30 @@ Everything goes through one command: `/agent [options] <task>`.
126
171
  | Widget / overlay | `d` `d` | Detach the agent, keeping its session (twice to confirm) |
127
172
  | Widget / overlay | `Esc` | Back |
128
173
  | Overlay | `Enter` | Steer mid-turn, or start another turn when idle |
129
- | Overlay | `j` | Join the conversation into the main context |
174
+ | Overlay | `s` | Squash the conversation into the main context |
175
+ | Overlay | `r` | Rebase the raw conversation into the main context (fast-forward only) |
130
176
  | Overlay | `c` | Copy the latest response |
131
177
  | Overlay | scroll · `End` | Pause tail-following · resume it |
132
178
 
133
179
  ## Good to know
134
180
 
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
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.
138
182
  - `c` copies via `pbcopy`, so it's macOS-only for now.
139
183
  - The overlay caps very large tool outputs and omits thinking entries.
140
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
+
141
195
  ## Under the hood
142
196
 
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).
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).
144
198
 
145
199
  ---
146
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,
@@ -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
 
package/command-line.ts CHANGED
@@ -57,7 +57,7 @@ export type AgentSemanticToken = {
57
57
 
58
58
  export type AgentArgumentScan = {
59
59
  isolate: boolean;
60
- context: boolean;
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: "context",
119
- names: ["-j", "--join"],
118
+ semanticId: "squash",
119
+ names: ["-s", "--squash"],
120
120
  role: "extension",
121
121
  arity: "boolean",
122
122
  autocomplete: true,
123
- description: "Join the completed result into the main agent context",
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 context = false;
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 === "context") {
397
- context = true;
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
- context,
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] [-j|--join] "<task>"`,
490
+ `Usage: /${command} [pi options] [-m MODELNAME] [-i|--isolate] [-s|--squash] "<task>"`,
491
491
  );
492
492
  return {
493
493
  isolate: scan.isolate,
494
- context: scan.context,
494
+ squash: scan.squash,
495
495
  forwardedArgs: scan.forwardedArgs,
496
496
  task: scan.task,
497
497
  warnings: scan.warnings,
package/index.ts CHANGED
@@ -3,11 +3,12 @@ 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";
6
+ import { createRebaseDelivery, handleAgentCommand } from "./runner.js";
7
7
  import type {
8
8
  AgentCommandDetails,
9
9
  DetachedEntryData,
10
10
  ExtensionAPI,
11
+ ExtensionCommandContext,
11
12
  RunningAgent,
12
13
  } from "./shared.js";
13
14
  import { DETACHED_ENTRY_TYPE, MESSAGE_TYPE } from "./shared.js";
@@ -16,10 +17,13 @@ import { UserAgentWidget } from "./widget.js";
16
17
 
17
18
  export default function userAgent(pi: ExtensionAPI): void {
18
19
  const runningAgents = new Set<RunningAgent>();
20
+ // The session every agent was dispatched from; rebase fast-forwards onto it.
21
+ let mainSessionContext: ExtensionCommandContext | undefined;
19
22
  const widget = new UserAgentWidget(
20
23
  runningAgents,
21
24
  (message) => pi.sendMessage(message, { triggerTurn: true }),
22
25
  (sessionId) => pi.appendEntry<DetachedEntryData>(DETACHED_ENTRY_TYPE, { sessionId }),
26
+ createRebaseDelivery(pi, () => mainSessionContext),
23
27
  );
24
28
  let shuttingDown = false;
25
29
  let nextAgentNumber = 0;
@@ -27,12 +31,13 @@ export default function userAgent(pi: ExtensionAPI): void {
27
31
  registerUserAgentRenderer(pi);
28
32
  registerAgentAutocomplete(pi);
29
33
 
30
- pi.on("message_end", (event) => confirmMainContextJoin(event, widget));
34
+ pi.on("message_end", (event) => confirmMainContextSquash(event, widget));
31
35
 
32
36
  pi.registerCommand("agent", {
33
37
  description:
34
38
  "Run a background SDK agent with current session context; forwards pi CLI options like --thinking (-i/--isolate for none)",
35
39
  handler: async (args, ctx) => {
40
+ mainSessionContext = ctx;
36
41
  await handleAgentCommand(
37
42
  pi,
38
43
  runningAgents,
@@ -63,19 +68,19 @@ export default function userAgent(pi: ExtensionAPI): void {
63
68
  });
64
69
  }
65
70
 
66
- export function confirmMainContextJoin(
71
+ export function confirmMainContextSquash(
67
72
  event: MessageEndEvent,
68
73
  widget: UserAgentWidget,
69
74
  ): MessageEndEventResult | undefined {
70
75
  if (event.message.role !== "custom" || event.message.customType !== MESSAGE_TYPE) return;
71
76
  const details = event.message.details as AgentCommandDetails | undefined;
72
- if (!details?.agentId || details.mainContextState !== "will-join") return;
73
- widget.confirmMainContextJoin(details.agentId);
74
- details.mainContextState = "joined";
77
+ if (!details?.agentId || details.mainContextState !== "will-squash") return;
78
+ widget.confirmMainContextSquash(details.agentId);
79
+ details.mainContextState = "squashed";
75
80
  return {
76
81
  message: {
77
82
  ...event.message,
78
- details: { ...details, mainContextState: "joined" },
83
+ details: { ...details, mainContextState: "squashed" },
79
84
  },
80
85
  };
81
86
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@giladbarnea/pi-user-agents",
3
- "version": "0.0.4",
3
+ "version": "0.0.5",
4
4
  "description": "Run, view, and control background Pi agents.",
5
5
  "keywords": [
6
6
  "pi-package",