@giladbarnea/pi-user-agents 0.0.5 → 0.0.7

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,9 +16,9 @@ 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 `-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`.
19
+ Default completion writes no entry or message to the parent session. The widget and agent overlay show the response. `-s` sends a hidden custom message with `{ triggerTurn: true }`, steered mid-turn or answered immediately.
20
20
 
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.
21
+ Immediate `-s` delivery and late `s` squash delivery share one canonical message builder and use `display: false`. Both paths preserve the selected conversation and `{ triggerTurn: true }` delivery. `transcript.ts` renders old completion entries and messages as zero lines, preventing Pi's fallback renderer from showing them. Command errors and detach, attach, and rebase breadcrumbs remain visible.
22
22
 
23
23
  ## Squashed message format
24
24
 
@@ -46,7 +46,7 @@ A child compaction passes through deliberately and lands as a real `compaction`
46
46
 
47
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
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.
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 (detach and attach 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
50
 
51
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
52
 
@@ -67,6 +67,16 @@ The file itself appears on the child's first assistant message (`_persist` defer
67
67
 
68
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
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.
79
+
70
80
  ## Runtime sharing
71
81
 
72
82
  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/README.md CHANGED
@@ -81,7 +81,7 @@ Press `←` or `↓` from the editor to focus the widget, pick an agent, press `
81
81
 
82
82
  A background agent is not fire-and-forget — it's a session you can talk to.
83
83
 
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.
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. Read each response in the agent overlay.
85
85
 
86
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
87
 
@@ -95,18 +95,20 @@ Detached session 0199c4f2-8b1a-7c3d-9e05-6a2f18d7b4ce
95
95
 
96
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
97
 
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
+
98
100
  ### 5. Squash — or don't
99
101
 
100
102
  This is what makes user agents different from subagents: **by default, the main agent never learns any of this happened.**
101
103
 
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.
104
+ Completed responses stay in the agent overlay, accessible through the widget below the editor. Completion adds no transcript card. Ask an agent ten questions and your main agent's token budget does not move.
103
105
 
104
106
  When a result does belong in the main conversation, squash it in:
105
107
 
106
108
  - Dispatch with `-s`/`--squash` and the result is delivered automatically on completion.
107
109
  - Or press `s` in the overlay of any completed agent, whenever you decide it earned its place.
108
110
 
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.
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. Squash adds no transcript card. A squashed agent retires; its overlay stays readable.
110
112
 
111
113
  ### 6. Or rebase — rewrite history
112
114
 
@@ -146,7 +148,7 @@ And what you do type by hand is checked live, as you type. Valid options and val
146
148
 
147
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.
148
150
 
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`.
151
+ The widget does not survive a session reload. Each agent's session remains on disk, accessible through `/resume` or `/agent-attach`. Completion cards from older versions stay hidden.
150
152
 
151
153
  ### 🌡️ A context meter in one cell
152
154
 
@@ -154,7 +156,7 @@ Each row carries the agent's own footer gauge: it fills `▁▂▃▄▅▆▇
154
156
 
155
157
  ## Reference
156
158
 
157
- 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.
158
160
 
159
161
  | Flag | Effect |
160
162
  |---|---|
@@ -187,6 +189,7 @@ Everything goes through one command: `/agent [options] <task>`.
187
189
  - [x] Agents stay alive until you end them — steer, interrupt, resume, across turns.
188
190
  - [x] Squash: a compact record of the exchange, delivered into the main context.
189
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.
190
193
  - [x] Live syntax coloring and eager autocomplete for the `/agent` line.
191
194
  - [ ] Render an agent's compaction event in the overlay.
192
195
  - [ ] Wire the accepted-but-inert `pi` options.
@@ -194,7 +197,7 @@ Everything goes through one command: `/agent [options] <task>`.
194
197
 
195
198
  ## Under the hood
196
199
 
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).
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).
198
201
 
199
202
  ---
200
203
 
package/agent-viewer.ts CHANGED
@@ -102,7 +102,14 @@ function describeAssistantPart(part: AssistantContentPart): ActivityDescription
102
102
 
103
103
  export function renderActivity(description: ActivityDescription, width: number, theme: Theme): string {
104
104
  if (width <= 0) return "";
105
- const normalized = description.text.replace(/\s+/g, " ").trim();
105
+ const limit = Math.min(4096, Math.max(128, width * 4));
106
+ const text = description.text;
107
+ const bounded = text.length <= limit
108
+ ? text
109
+ : description.truncation === "tail"
110
+ ? `${text.slice(0, limit - 1)}…`
111
+ : `${text.slice(0, Math.ceil((limit - 1) / 2))}…${text.slice(-Math.floor((limit - 1) / 2))}`;
112
+ const normalized = bounded.replace(/\s+/g, " ").trim();
106
113
  const dim = (text: string) => theme.fg("dim", text);
107
114
  const markdown = new Markdown(normalized, 0, 0, dimMarkdownThemeFromTheme(theme), { color: dim });
108
115
  const rendered = markdown
@@ -158,7 +165,7 @@ export class AgentViewer implements Component {
158
165
  this.tui.requestRender(),
159
166
  );
160
167
  private composer: Input | undefined;
161
- /** Rich tool rendering is far too costly to repeat on every 100 ms widget tick. */
168
+ /** Rich tool rendering is far too costly to repeat on every widget tick. */
162
169
  private renderedContent: { key: string; lines: string[] } | undefined;
163
170
 
164
171
  constructor(
@@ -364,7 +371,9 @@ export class AgentViewer implements Component {
364
371
  return lines;
365
372
  }
366
373
 
367
- invalidate(): void {}
374
+ invalidate(): void {
375
+ this.renderedContent = undefined;
376
+ }
368
377
 
369
378
  dispose(): void {}
370
379
 
package/attach.ts ADDED
@@ -0,0 +1,339 @@
1
+ import { existsSync, readdirSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { createAgentSessionFromServices, SessionManager } from "@earendil-works/pi-coding-agent";
4
+ import { conversationFingerprint } from "./rebase.js";
5
+ import {
6
+ assistantText,
7
+ buildChildResourceLoaderOptions,
8
+ createChildServices,
9
+ DISPATCH_PREAMBLE,
10
+ parseForwardedArgs,
11
+ reportAgentFailure,
12
+ resolveToolOptions,
13
+ runChildTurns,
14
+ subscribeToChildSession,
15
+ waitForInstruction,
16
+ } from "./runner.js";
17
+ import type {
18
+ AgentMessage,
19
+ AgentSession,
20
+ AttachedEntryData,
21
+ DispatchRecordData,
22
+ ExtensionAPI,
23
+ ExtensionCommandContext,
24
+ Model,
25
+ RunningAgent,
26
+ } from "./shared.js";
27
+ import {
28
+ ATTACHED_ENTRY_TYPE,
29
+ DISPATCH_ENTRY_TYPE,
30
+ errorMessage,
31
+ formatModel,
32
+ formatModelLabel,
33
+ logSteering,
34
+ } from "./shared.js";
35
+ import { buildAgentResultMessage, reportCommandError } from "./transcript.js";
36
+ import type { UserAgentWidget } from "./widget.js";
37
+
38
+ /** A dispatch base that can never fingerprint-match a live context, so rebase stays withheld. */
39
+ export const UNKNOWN_DISPATCH_BASE = "unknown-dispatch-base";
40
+
41
+ export type DispatchBoundary = {
42
+ /** The main-session context the child inherited at dispatch — the rebase fast-forward base. */
43
+ base: AgentMessage[];
44
+ /** The child's own conversation, starting at the preambled dispatch message. */
45
+ conversation: AgentMessage[];
46
+ /** The dispatch task, preamble stripped. */
47
+ task: string;
48
+ };
49
+
50
+ /**
51
+ * Split a child's resolved context at its dispatch boundary — the first user message carrying
52
+ * the dispatch preamble. Undefined when no message carries it (a /fork child, or a child whose
53
+ * own compaction summarized the boundary away).
54
+ */
55
+ export function splitAtDispatchBoundary(
56
+ messages: readonly AgentMessage[],
57
+ ): DispatchBoundary | undefined {
58
+ for (const [index, message] of messages.entries()) {
59
+ if (message.role !== "user") continue;
60
+ const text = userMessageText(message);
61
+ if (!text.startsWith(DISPATCH_PREAMBLE)) continue;
62
+ return {
63
+ base: messages.slice(0, index),
64
+ conversation: messages.slice(index),
65
+ task: text.slice(DISPATCH_PREAMBLE.length),
66
+ };
67
+ }
68
+ return undefined;
69
+ }
70
+
71
+ function userMessageText(message: Extract<AgentMessage, { role: "user" }>): string {
72
+ if (typeof message.content === "string") return message.content;
73
+ return message.content
74
+ .flatMap((part) => (part.type === "text" ? [part.text] : []))
75
+ .join("\n");
76
+ }
77
+
78
+ export type ChildSessionFileMatch = { sessionId: string; file: string };
79
+
80
+ /**
81
+ * The session files in a directory whose id matches the query exactly or by prefix.
82
+ * Pi names session files `<timestamp>_<session-id>.jsonl`, so matching reads names only.
83
+ */
84
+ export function matchChildSessionFiles(
85
+ query: string,
86
+ sessionDirectory: string,
87
+ ): ChildSessionFileMatch[] {
88
+ const fileNames = existsSync(sessionDirectory) ? readdirSync(sessionDirectory) : [];
89
+ return fileNames.flatMap((name) => {
90
+ const sessionId = sessionIdFromFileName(name);
91
+ if (sessionId === undefined || !sessionId.startsWith(query)) return [];
92
+ return [{ sessionId, file: join(sessionDirectory, name) }];
93
+ });
94
+ }
95
+
96
+ function sessionIdFromFileName(fileName: string): string | undefined {
97
+ if (!fileName.endsWith(".jsonl")) return undefined;
98
+ const separator = fileName.indexOf("_");
99
+ if (separator === -1) return undefined;
100
+ return fileName.slice(separator + 1, -".jsonl".length);
101
+ }
102
+
103
+ /** The dispatch record persisted in the child's file, or undefined for pre-record and /fork children. */
104
+ export function readDispatchRecord(
105
+ childSessionManager: SessionManager,
106
+ ): DispatchRecordData | undefined {
107
+ for (const entry of childSessionManager.getEntries()) {
108
+ if (entry.type === "custom" && entry.customType === DISPATCH_ENTRY_TYPE)
109
+ return entry.data as DispatchRecordData | undefined;
110
+ }
111
+ return undefined;
112
+ }
113
+
114
+ /**
115
+ * Rebuild an idle, steerable RunningAgent from a reopened child session and its resolved model.
116
+ * The child's own conversation, task, latest response, and rebase base all come from the file;
117
+ * the dispatch record refines the task and context flag, and with no recognizable dispatch
118
+ * boundary the whole context attaches and rebase stays withheld.
119
+ */
120
+ export function buildAttachedAgent(
121
+ sequenceNumber: number,
122
+ childSessionManager: SessionManager,
123
+ model: Model,
124
+ invocation: string,
125
+ record?: DispatchRecordData,
126
+ ): RunningAgent {
127
+ const contextMessages = childSessionManager.buildSessionContext().messages;
128
+ const boundary = splitAtDispatchBoundary(contextMessages);
129
+ const conversationMessages = structuredClone(boundary?.conversation ?? contextMessages);
130
+ const firstUserMessage = contextMessages.find((message) => message.role === "user");
131
+ const lastAssistantMessage = conversationMessages.findLast(
132
+ (message): message is Extract<AgentMessage, { role: "assistant" }> =>
133
+ message.role === "assistant",
134
+ );
135
+ const responseText = lastAssistantMessage ? assistantText(lastAssistantMessage).trim() : "";
136
+ const now = Date.now();
137
+ const agent: RunningAgent = {
138
+ id: `user-${sequenceNumber.toString(36)}`,
139
+ sessionId: childSessionManager.getSessionId(),
140
+ command: "agent",
141
+ inheritedContext: record
142
+ ? !record.isolate
143
+ : boundary
144
+ ? boundary.base.length > 0
145
+ : true,
146
+ model: formatModel(model),
147
+ modelLabel: formatModelLabel(model),
148
+ task:
149
+ record?.task ??
150
+ boundary?.task ??
151
+ (firstUserMessage === undefined ? "(no messages)" : userMessageText(firstUserMessage)),
152
+ invocation,
153
+ notifyMainAgent: false,
154
+ dispatchBaseFingerprint: boundary
155
+ ? conversationFingerprint(boundary.base)
156
+ : UNKNOWN_DISPATCH_BASE,
157
+ mainContextState: "separate",
158
+ status: "idle",
159
+ startedAt: now,
160
+ turnStartedAt: now,
161
+ completedAt: now,
162
+ activeTools: new Map(),
163
+ toolUses: 0,
164
+ turnCount: 0,
165
+ responseText,
166
+ conversationMessages,
167
+ finished: Promise.resolve(),
168
+ };
169
+ if (responseText)
170
+ agent.pendingSquashMessage = buildAgentResultMessage(
171
+ agent,
172
+ { ok: true, response: responseText },
173
+ { display: false },
174
+ );
175
+ return agent;
176
+ }
177
+
178
+ /**
179
+ * The attached agent's lifecycle: bind the child's extensions, park until the user steers or
180
+ * retires it, then run turns exactly like a dispatched child. Every failure — the bind's
181
+ * included — settles through the shared error path, and the end shuts the child's extensions
182
+ * down.
183
+ */
184
+ export async function runAttachedTurns(
185
+ pi: ExtensionAPI,
186
+ isShuttingDown: () => boolean,
187
+ session: AgentSession,
188
+ runningAgent: RunningAgent,
189
+ widget: UserAgentWidget,
190
+ ): Promise<void> {
191
+ let unsubscribe: (() => void) | undefined;
192
+ try {
193
+ await session.bindExtensions({ mode: "print" });
194
+ unsubscribe = subscribeToChildSession(session, runningAgent, widget);
195
+ // The user can detach during the extension bind, before this lifecycle parks.
196
+ if (runningAgent.aborted) return;
197
+ const instruction = await waitForInstruction(runningAgent, widget);
198
+ if (instruction !== undefined)
199
+ await runChildTurns(pi, isShuttingDown, instruction, session, runningAgent, widget);
200
+ } catch (error) {
201
+ reportAgentFailure(pi, isShuttingDown, runningAgent, widget, error);
202
+ } finally {
203
+ unsubscribe?.();
204
+ logSteering(runningAgent.id, "session-shutdown", { streaming: session.isStreaming });
205
+ await session.extensionRunner.emit({ type: "session_shutdown", reason: "quit" });
206
+ }
207
+ }
208
+
209
+ export async function handleAttachCommand(
210
+ pi: ExtensionAPI,
211
+ runningAgents: Set<RunningAgent>,
212
+ widget: UserAgentWidget,
213
+ isShuttingDown: () => boolean,
214
+ nextAgentNumber: () => number,
215
+ args: string,
216
+ ctx: ExtensionCommandContext,
217
+ ): Promise<void> {
218
+ try {
219
+ await attachUserAgent(pi, runningAgents, widget, isShuttingDown, nextAgentNumber, args, ctx);
220
+ } catch (error) {
221
+ reportCommandError(pi, "agent-attach", args, ctx, errorMessage(error));
222
+ }
223
+ }
224
+
225
+ /**
226
+ * Reattach a detached child session as a live, parked agent row. Idempotent for a session
227
+ * that already has a row; errors for anything that is not a child session of this session.
228
+ * Both success states end by offering to open the agent's overlay.
229
+ */
230
+ async function attachUserAgent(
231
+ pi: ExtensionAPI,
232
+ runningAgents: Set<RunningAgent>,
233
+ widget: UserAgentWidget,
234
+ isShuttingDown: () => boolean,
235
+ nextAgentNumber: () => number,
236
+ args: string,
237
+ ctx: ExtensionCommandContext,
238
+ ): Promise<void> {
239
+ const query = args.trim();
240
+ if (!query || /\s/.test(query)) throw new Error("Usage: /agent-attach <child-session-id>");
241
+ if (ctx.hasUI) widget.setUI(ctx.ui);
242
+
243
+ // One resolution over the whole universe — attached rows and files on disk together — so a
244
+ // prefix unique among rows cannot mask an ambiguity with a detached session, and a live row
245
+ // whose file is not written yet still resolves. Dispatch creates children in pi's default
246
+ // session directory for the cwd; that is where the files are.
247
+ const attachedIds = widget.matchAttachedSessionIds(query);
248
+ const childSessionDirectory = SessionManager.create(ctx.cwd).getSessionDir();
249
+ const fileMatches = matchChildSessionFiles(query, childSessionDirectory);
250
+ const matchedSessionIds = [
251
+ ...new Set([...attachedIds, ...fileMatches.map((match) => match.sessionId)]),
252
+ ];
253
+ if (matchedSessionIds.length === 0) throw new Error(`No agent session matches "${query}"`);
254
+ if (matchedSessionIds.length > 1)
255
+ throw new Error(`"${query}" matches ${matchedSessionIds.length} sessions; use more characters`);
256
+ const sessionId = matchedSessionIds[0]!;
257
+
258
+ if (attachedIds.includes(sessionId)) {
259
+ await confirmViewAgent(ctx, widget, sessionId, "Agent already attached");
260
+ return;
261
+ }
262
+
263
+ const mainSessionFile = ctx.sessionManager.getSessionFile();
264
+ if (!mainSessionFile)
265
+ throw new Error("This session has no file; only a persisted session can attach agents");
266
+ const childSessionFile = fileMatches.find((match) => match.sessionId === sessionId)!.file;
267
+ const childSessionManager = SessionManager.open(childSessionFile);
268
+ if (childSessionManager.getHeader()?.parentSession !== mainSessionFile)
269
+ throw new Error(
270
+ `Session ${childSessionManager.getSessionId()} is not a child session of this session`,
271
+ );
272
+
273
+ // The record replays through the same projections dispatch uses; a record-less child (an old
274
+ // dispatch, or a /fork) restores pi defaults.
275
+ const record = readDispatchRecord(childSessionManager);
276
+ const dispatchArgs = parseForwardedArgs(record?.forwardedArgs ?? []);
277
+ const services = await createChildServices(
278
+ ctx,
279
+ buildChildResourceLoaderOptions(dispatchArgs, ctx.cwd),
280
+ );
281
+ // A non-empty session manager takes pi's resume path: messages, model, and thinking level
282
+ // are restored from the child's own file; tools and resources come from the dispatch record.
283
+ const { session, modelFallbackMessage } = await createAgentSessionFromServices({
284
+ services,
285
+ sessionManager: childSessionManager,
286
+ ...resolveToolOptions(dispatchArgs),
287
+ });
288
+ const model = session.agent.state.model as Model | undefined;
289
+ if (!model) {
290
+ session.dispose();
291
+ throw new Error(modelFallbackMessage ?? "No model is available for the attached session");
292
+ }
293
+ if (modelFallbackMessage && ctx.hasUI) ctx.ui.notify(modelFallbackMessage, "warning");
294
+
295
+ const runningAgent = buildAttachedAgent(
296
+ nextAgentNumber(),
297
+ childSessionManager,
298
+ model,
299
+ `/agent-attach ${query}`,
300
+ record,
301
+ );
302
+ runningAgent.session = session;
303
+ runningAgents.add(runningAgent);
304
+ logSteering(runningAgent.id, "agent-attached", { sessionId: runningAgent.sessionId });
305
+ if (ctx.hasUI) {
306
+ widget.ensureTimer();
307
+ widget.update();
308
+ }
309
+ // No awaits between the add above and this assignment: every later failure settles inside
310
+ // the lifecycle, so the row can never outlive its own removal hook.
311
+ runningAgent.finished = runAttachedTurns(
312
+ pi,
313
+ isShuttingDown,
314
+ session,
315
+ runningAgent,
316
+ widget,
317
+ ).finally(() => {
318
+ logSteering(runningAgent.id, "agent-disposed", { status: runningAgent.status });
319
+ runningAgent.session?.dispose();
320
+ runningAgents.delete(runningAgent);
321
+ widget.update();
322
+ });
323
+ // The detach breadcrumb's counterpart: after a reload the transcript still tells the truth.
324
+ pi.appendEntry<AttachedEntryData>(ATTACHED_ENTRY_TYPE, { sessionId: runningAgent.sessionId });
325
+ if (runningAgent.aborted) return;
326
+ await confirmViewAgent(ctx, widget, runningAgent.sessionId, "Agent attached");
327
+ }
328
+
329
+ /** The follow-up both success states share: offer to open the agent's overlay. */
330
+ async function confirmViewAgent(
331
+ ctx: ExtensionCommandContext,
332
+ widget: UserAgentWidget,
333
+ sessionId: string,
334
+ title: string,
335
+ ): Promise<void> {
336
+ if (!ctx.hasUI) return;
337
+ const view = await ctx.ui.confirm(title, "View agent?");
338
+ if (view) widget.openViewer(sessionId);
339
+ }
package/index.ts CHANGED
@@ -2,6 +2,7 @@ import type {
2
2
  MessageEndEvent,
3
3
  MessageEndEventResult,
4
4
  } from "@earendil-works/pi-coding-agent";
5
+ import { handleAttachCommand } from "./attach.js";
5
6
  import { registerAgentAutocomplete } from "./autocomplete.js";
6
7
  import { createRebaseDelivery, handleAgentCommand } from "./runner.js";
7
8
  import type {
@@ -52,6 +53,22 @@ export default function userAgent(pi: ExtensionAPI): void {
52
53
  },
53
54
  });
54
55
 
56
+ pi.registerCommand("agent-attach", {
57
+ description: "Reattach a detached agent session by id, back into the widget",
58
+ handler: async (args, ctx) => {
59
+ mainSessionContext = ctx;
60
+ await handleAttachCommand(
61
+ pi,
62
+ runningAgents,
63
+ widget,
64
+ () => shuttingDown,
65
+ () => ++nextAgentNumber,
66
+ args,
67
+ ctx,
68
+ );
69
+ },
70
+ });
71
+
55
72
  pi.on("session_shutdown", async () => {
56
73
  shuttingDown = true;
57
74
  widget.dispose();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@giladbarnea/pi-user-agents",
3
- "version": "0.0.5",
3
+ "version": "0.0.7",
4
4
  "description": "Run, view, and control background Pi agents.",
5
5
  "keywords": [
6
6
  "pi-package",
package/rebase.ts CHANGED
@@ -8,10 +8,21 @@ type MainSessionManager = ExtensionCommandContext["sessionManager"];
8
8
  * Fingerprint a context snapshot for the rebase fast-forward precondition: the main session's
9
9
  * live context must still fingerprint-equal the base the child was dispatched from.
10
10
  *
11
+ * Compares content, not file bookkeeping: rebuilding custom, compaction, and branch summaries
12
+ * from a session file re-stamps their timestamps (and a branch summary's fromId), so a base
13
+ * round-tripped through a child's file would never equal main's live context otherwise.
14
+ *
11
15
  * @example conversationFingerprint([]) === conversationFingerprint([]) // true
12
16
  */
13
17
  export function conversationFingerprint(messages: readonly AgentMessage[]): string {
14
- return JSON.stringify(messages);
18
+ return JSON.stringify(messages.map(fingerprintedMessage));
19
+ }
20
+
21
+ function fingerprintedMessage(message: AgentMessage): Omit<AgentMessage, "timestamp"> {
22
+ const { timestamp: _timestamp, ...content } = message;
23
+ if (content.role !== "branchSummary") return content;
24
+ const { fromId: _fromId, ...branchContent } = content;
25
+ return branchContent;
15
26
  }
16
27
 
17
28
  /** Rebuilding a session-sized context string is only worth doing when the branch tip moved. */
package/runner.ts CHANGED
@@ -33,7 +33,9 @@ import type {
33
33
  RunningAgent,
34
34
  ThinkingLevel,
35
35
  } from "./shared.js";
36
+ import type { DispatchRecordData } from "./shared.js";
36
37
  import {
38
+ DISPATCH_ENTRY_TYPE,
37
39
  errorMessage,
38
40
  formatModel,
39
41
  formatModelLabel,
@@ -90,17 +92,10 @@ async function startUserAgent(
90
92
  ): Promise<void> {
91
93
  const parsed = parseAgentCommand(args, command);
92
94
  const parsedForwardedArgs = parseForwardedArgs(parsed.forwardedArgs);
93
- const agentDir = getAgentDir();
94
- const settingsManager = SettingsManager.create(ctx.cwd, agentDir, {
95
- projectTrusted: ctx.isProjectTrusted(),
96
- });
97
- const services = await createAgentSessionServices({
98
- cwd: ctx.cwd,
99
- agentDir,
100
- settingsManager,
101
- modelRuntime: (ctx.modelRegistry as unknown as { runtime: ModelRuntime }).runtime,
102
- resourceLoaderOptions: buildChildResourceLoaderOptions(parsedForwardedArgs, ctx.cwd),
103
- });
95
+ const services = await createChildServices(
96
+ ctx,
97
+ buildChildResourceLoaderOptions(parsedForwardedArgs, ctx.cwd),
98
+ );
104
99
  const forwarded = resolveForwardedOptions(parsedForwardedArgs, services.modelRuntime);
105
100
  const selectedModel = forwarded.model ?? ctx.model;
106
101
  if (!selectedModel) throw new Error("No current model is selected; pass -m MODELNAME");
@@ -112,6 +107,12 @@ async function startUserAgent(
112
107
  const childSessionManager = SessionManager.create(services.cwd, undefined, {
113
108
  parentSession: ctx.sessionManager.getSessionFile(),
114
109
  });
110
+ // The dispatch record: /agent-attach restores tool and resource options from it later.
111
+ childSessionManager.appendCustomEntry(DISPATCH_ENTRY_TYPE, {
112
+ forwardedArgs: parsed.forwardedArgs,
113
+ task: parsed.task,
114
+ isolate: parsed.isolate,
115
+ } satisfies DispatchRecordData);
115
116
  const runningAgent = createRunningAgent(
116
117
  nextAgentNumber(),
117
118
  childSessionManager.getSessionId(),
@@ -191,14 +192,9 @@ function postUserAgentResult(
191
192
  notifyMainAgent: boolean,
192
193
  message: AgentResultMessage,
193
194
  ): boolean {
194
- if (isShuttingDown()) return false;
195
- if (notifyMainAgent) {
196
- // Steers into a streaming turn, or triggers an immediate response when idle.
197
- pi.sendMessage(message, { triggerTurn: true });
198
- } else {
199
- // TUI-only session entry: renders in the transcript but never enters the main agent's context.
200
- pi.appendEntry(message.customType, { content: message.content, details: message.details });
201
- }
195
+ if (isShuttingDown() || !notifyMainAgent) return false;
196
+ // Steers into a streaming turn, or triggers an immediate response when idle.
197
+ pi.sendMessage(message, { triggerTurn: true });
202
198
  return true;
203
199
  }
204
200
 
@@ -263,7 +259,7 @@ export function createRebaseDelivery(
263
259
  }
264
260
 
265
261
  /** Session inputs derived from forwarded pi CLI options, ready to hand to the child session. */
266
- type ChildResourceLoaderOptions = {
262
+ export type ChildResourceLoaderOptions = {
267
263
  systemPrompt?: string;
268
264
  appendSystemPrompt?: string[];
269
265
  additionalExtensionPaths?: string[];
@@ -276,14 +272,37 @@ type ChildResourceLoaderOptions = {
276
272
  noContextFiles?: boolean;
277
273
  };
278
274
 
279
- type ForwardedOptions = {
280
- model?: Model;
281
- thinkingLevel?: ThinkingLevel;
275
+ export type ChildToolOptions = {
282
276
  tools?: string[];
283
277
  excludeTools?: string[];
284
278
  noTools?: "all" | "builtin";
285
279
  };
286
280
 
281
+ type ForwardedOptions = ChildToolOptions & {
282
+ model?: Model;
283
+ thinkingLevel?: ThinkingLevel;
284
+ };
285
+
286
+ /** The cwd-bound services for one child session; the parent's ModelRuntime rides along (see INTERNALS). */
287
+ export function createChildServices(
288
+ ctx: ExtensionCommandContext,
289
+ resourceLoaderOptions?: ChildResourceLoaderOptions,
290
+ ): Promise<AgentSessionServices> {
291
+ const agentDir = getAgentDir();
292
+ const settingsManager = SettingsManager.create(ctx.cwd, agentDir, {
293
+ projectTrusted: ctx.isProjectTrusted(),
294
+ });
295
+ return createAgentSessionServices({
296
+ cwd: ctx.cwd,
297
+ agentDir,
298
+ settingsManager,
299
+ // The extension facade wraps the parent's runtime as a plain property; sharing it keeps
300
+ // extension-registered providers (their process-global dedup guard) available to the child.
301
+ modelRuntime: (ctx.modelRegistry as unknown as { runtime: ModelRuntime }).runtime,
302
+ resourceLoaderOptions,
303
+ });
304
+ }
305
+
287
306
  /** Parse forwarded pi CLI tokens once before model and session resolution. */
288
307
  export function parseForwardedArgs(forwardedArgs: string[]): Args {
289
308
  const parsed = parseArgs(forwardedArgs);
@@ -382,12 +401,17 @@ export function resolveForwardedOptions(
382
401
  }
383
402
  }
384
403
  if (parsed.thinking) forwarded.thinkingLevel = parsed.thinking;
404
+ return { ...forwarded, ...resolveToolOptions(parsed) };
405
+ }
385
406
 
386
- if (parsed.noTools) forwarded.noTools = "all";
387
- else if (parsed.noBuiltinTools) forwarded.noTools = "builtin";
388
- if (parsed.tools) forwarded.tools = parsed.tools;
389
- if (parsed.excludeTools) forwarded.excludeTools = parsed.excludeTools;
390
- return forwarded;
407
+ /** Map parsed pi tool options onto child-session inputs; dispatch and attach share this. */
408
+ export function resolveToolOptions(parsed: Args): ChildToolOptions {
409
+ const options: ChildToolOptions = {};
410
+ if (parsed.noTools) options.noTools = "all";
411
+ else if (parsed.noBuiltinTools) options.noTools = "builtin";
412
+ if (parsed.tools) options.tools = parsed.tools;
413
+ if (parsed.excludeTools) options.excludeTools = parsed.excludeTools;
414
+ return options;
391
415
  }
392
416
 
393
417
  export function buildChildResourceLoaderOptions(
@@ -449,29 +473,9 @@ async function runAgentLifecycle(
449
473
  widget,
450
474
  );
451
475
  unsubscribe = subscribeToChildSession(session, runningAgent, widget);
452
- await runChildTurns(pi, isShuttingDown, task, session, runningAgent, widget);
476
+ await runChildTurns(pi, isShuttingDown, DISPATCH_PREAMBLE + task, session, runningAgent, widget);
453
477
  } catch (error) {
454
- if (runningAgent.aborted) {
455
- logSteering(runningAgent.id, "agent-aborted");
456
- return;
457
- }
458
- const message = errorMessage(error);
459
- logSteering(runningAgent.id, "agent-error", { error: message });
460
- runningAgent.status = "error";
461
- runningAgent.completedAt = Date.now();
462
- runningAgent.error = message;
463
- if (runningAgent.notifyMainAgent && isShuttingDown()) {
464
- runningAgent.mainContextState = "separate";
465
- return;
466
- }
467
- const resultMessage = buildAgentResultMessage(
468
- runningAgent,
469
- { ok: false, error: message },
470
- { display: runningAgent.notifyMainAgent },
471
- );
472
- widget.addCompleted(runningAgent, resultMessage, { squashable: !runningAgent.notifyMainAgent });
473
- if (postUserAgentResult(pi, isShuttingDown, runningAgent.notifyMainAgent, resultMessage))
474
- runningAgent.status = "posted";
478
+ reportAgentFailure(pi, isShuttingDown, runningAgent, widget, error);
475
479
  } finally {
476
480
  unsubscribe?.();
477
481
  if (session) {
@@ -481,16 +485,51 @@ async function runAgentLifecycle(
481
485
  }
482
486
  }
483
487
 
484
- /** Run turns on an initialized child session until it posts, retires, or shuts down. */
488
+ /** Record a failed lifecycle in the widget and deliver it only when squash was requested. */
489
+ export function reportAgentFailure(
490
+ pi: ExtensionAPI,
491
+ isShuttingDown: () => boolean,
492
+ runningAgent: RunningAgent,
493
+ widget: UserAgentWidget,
494
+ error: unknown,
495
+ ): void {
496
+ if (runningAgent.aborted) {
497
+ logSteering(runningAgent.id, "agent-aborted");
498
+ return;
499
+ }
500
+ const message = errorMessage(error);
501
+ logSteering(runningAgent.id, "agent-error", { error: message });
502
+ runningAgent.status = "error";
503
+ runningAgent.completedAt = Date.now();
504
+ runningAgent.error = message;
505
+ if (runningAgent.notifyMainAgent && isShuttingDown()) {
506
+ runningAgent.mainContextState = "separate";
507
+ return;
508
+ }
509
+ const resultMessage = buildAgentResultMessage(
510
+ runningAgent,
511
+ { ok: false, error: message },
512
+ { display: false },
513
+ );
514
+ widget.addCompleted(runningAgent, resultMessage, { squashable: !runningAgent.notifyMainAgent });
515
+ if (postUserAgentResult(pi, isShuttingDown, runningAgent.notifyMainAgent, resultMessage))
516
+ runningAgent.status = "posted";
517
+ }
518
+
519
+ /** Every dispatch prefixes its task with this; attach recognizes it as the child's dispatch boundary. */
520
+ export const DISPATCH_PREAMBLE =
521
+ "You are running in an ephemeral, forked background process now, concurrently with the main session. ";
522
+
523
+ /** Run turns on an initialized child session until it posts, retires, or shuts down. The initial instruction is sent verbatim. */
485
524
  export async function runChildTurns(
486
525
  pi: ExtensionAPI,
487
526
  isShuttingDown: () => boolean,
488
- task: string,
527
+ initialInstruction: string,
489
528
  session: AgentSession,
490
529
  runningAgent: RunningAgent,
491
530
  widget: UserAgentWidget,
492
531
  ): Promise<void> {
493
- let instruction = `You are running in an ephemeral, forked background process now, concurrently with the main session. ${task}`;
532
+ let instruction = initialInstruction;
494
533
  while (true) {
495
534
  const turnMessageStart = session.agent.state.messages.length;
496
535
  if (!runningAgent.interruptRequested) {
@@ -512,7 +551,6 @@ export async function runChildTurns(
512
551
  { ok: true, response },
513
552
  { display: false },
514
553
  );
515
- postUserAgentResult(pi, isShuttingDown, false, resultMessage);
516
554
  runningAgent.pendingSquashMessage = runningAgent.notifyMainAgent
517
555
  ? undefined
518
556
  : resultMessage;
@@ -537,7 +575,7 @@ export async function runChildTurns(
537
575
  const resultMessage = buildAgentResultMessage(
538
576
  runningAgent,
539
577
  { ok: true, response },
540
- { display: runningAgent.notifyMainAgent },
578
+ { display: false },
541
579
  );
542
580
  if (runningAgent.notifyMainAgent) {
543
581
  if (isShuttingDown()) {
@@ -550,7 +588,6 @@ export async function runChildTurns(
550
588
  runningAgent.status = posted ? "posted" : runningAgent.status;
551
589
  return;
552
590
  }
553
- postUserAgentResult(pi, isShuttingDown, false, resultMessage);
554
591
  runningAgent.pendingSquashMessage = resultMessage;
555
592
  if (isShuttingDown()) return;
556
593
  runningAgent.status = "idle";
@@ -745,7 +782,7 @@ export function subscribeToChildSession(
745
782
  });
746
783
  }
747
784
 
748
- function assistantText(message: Extract<AgentMessage, { role: "assistant" }>): string {
785
+ export function assistantText(message: Extract<AgentMessage, { role: "assistant" }>): string {
749
786
  return message.content
750
787
  .filter((part) => part.type === "text")
751
788
  .map((part) => part.text)
package/shared.ts CHANGED
@@ -15,7 +15,7 @@ export type ThinkingLevel = ReturnType<ExtensionAPI["getThinkingLevel"]>;
15
15
  export type Theme = ExtensionCommandContext["ui"]["theme"];
16
16
  export type UIContext = ExtensionCommandContext["ui"];
17
17
 
18
- export type AgentCommandName = "agent";
18
+ export type AgentCommandName = "agent" | "agent-attach";
19
19
  export type MainContextState = "separate" | "will-squash" | "squashed" | "rebased";
20
20
  export type AgentStatus =
21
21
  | "starting"
@@ -44,6 +44,19 @@ export type DetachedEntryData = {
44
44
  sessionId: string;
45
45
  };
46
46
 
47
+ export type AttachedEntryData = {
48
+ sessionId: string;
49
+ };
50
+
51
+ /** Persisted in the child session at dispatch: the facts attach cannot recover from messages alone. */
52
+ export type DispatchRecordData = {
53
+ /** The pi CLI tokens the dispatch forwarded, e.g. ["--tools", "read,grep"]. */
54
+ forwardedArgs: string[];
55
+ /** The plain task, without the dispatch preamble. */
56
+ task: string;
57
+ isolate: boolean;
58
+ };
59
+
47
60
  export type RebaseStats = {
48
61
  messageCount: number;
49
62
  tokenEstimate: number;
@@ -75,7 +88,8 @@ export type AgentCommandDetails = {
75
88
  agentId?: string;
76
89
  command: AgentCommandName;
77
90
  mainContextState?: MainContextState;
78
- inheritedContext: boolean;
91
+ /** Absent on command-error cards: an error has no inherited-or-isolated notion. */
92
+ inheritedContext?: boolean;
79
93
  model: string;
80
94
  modelLabel: string;
81
95
  task: string;
@@ -84,8 +98,6 @@ export type AgentCommandDetails = {
84
98
  toolUses?: number;
85
99
  turnCount?: number;
86
100
  responseText?: string;
87
- /** Retained for completed entries written before responseText was persisted. */
88
- responsePreview?: string;
89
101
  error?: string;
90
102
  };
91
103
 
@@ -207,6 +219,8 @@ export class TimedConfirmation<Target> {
207
219
  export const MESSAGE_TYPE = "pi-user-agents";
208
220
  export const DETACHED_ENTRY_TYPE = "pi-user-agents-detached";
209
221
  export const REBASED_ENTRY_TYPE = "pi-user-agents-rebased";
222
+ export const ATTACHED_ENTRY_TYPE = "pi-user-agents-attached";
223
+ export const DISPATCH_ENTRY_TYPE = "pi-user-agents-dispatch";
210
224
  export const WIDGET_KEY = "pi-user-agents";
211
225
  export const STEERING_LOG_PATH = `${tmpdir()}/pi-user-agents-steer.log`;
212
226
 
@@ -278,14 +292,6 @@ export function truncatePlain(text: string, length: number): string {
278
292
  return `${line.slice(0, Math.max(0, length - 1))}…`;
279
293
  }
280
294
 
281
- export function extractTag(
282
- content: string,
283
- tag: "task" | "response" | "error" | "duration_ms",
284
- ): string | undefined {
285
- const match = content.match(new RegExp(`<${tag}>\\n?([\\s\\S]*?)\\n?</${tag}>`));
286
- return match?.[1]?.trim();
287
- }
288
-
289
295
  export function escapeAttribute(value: string): string {
290
296
  return value
291
297
  .replace(/&/g, "&amp;")
package/transcript.ts CHANGED
@@ -1,30 +1,25 @@
1
- import { getMarkdownTheme } from "@earendil-works/pi-coding-agent";
2
- import { type Component, Container, Markdown, Text } from "@earendil-works/pi-tui";
1
+ import { type Component, Text } from "@earendil-works/pi-tui";
3
2
  import type {
4
3
  AgentCommandDetails,
5
4
  AgentCommandName,
6
5
  AgentEntryData,
7
6
  AgentMessage,
8
7
  AgentResultMessage,
8
+ AttachedEntryData,
9
9
  DetachedEntryData,
10
10
  ExtensionAPI,
11
11
  ExtensionCommandContext,
12
12
  RebasedEntryData,
13
13
  RunningAgent,
14
- Theme,
15
14
  } from "./shared.js";
16
15
  import {
16
+ ATTACHED_ENTRY_TYPE,
17
17
  contextLabel,
18
18
  DETACHED_ENTRY_TYPE,
19
19
  escapeAttribute,
20
- extractTag,
21
- formatMs,
22
- formatToolUses,
23
- formatTurns,
24
20
  mainContextLabel,
25
21
  MESSAGE_TYPE,
26
22
  REBASED_ENTRY_TYPE,
27
- truncatePlain,
28
23
  } from "./shared.js";
29
24
 
30
25
  export function reportCommandError(
@@ -44,7 +39,6 @@ export function reportCommandError(
44
39
  display: true,
45
40
  details: {
46
41
  command,
47
- inheritedContext: command === "agent",
48
42
  model: "",
49
43
  modelLabel: "",
50
44
  task: args.trim(),
@@ -194,34 +188,25 @@ export function formatStartNotification(agent: RunningAgent): string {
194
188
  }
195
189
 
196
190
  export function registerUserAgentRenderer(pi: ExtensionAPI): void {
197
- pi.registerMessageRenderer<AgentCommandDetails>(
198
- MESSAGE_TYPE,
199
- (message, { expanded }, theme) =>
200
- liveAgentCard(
201
- () => customMessageContentText(message.content),
202
- () => message.details,
203
- expanded,
204
- theme,
205
- ),
206
- );
207
- pi.registerEntryRenderer<AgentEntryData>(
208
- MESSAGE_TYPE,
209
- (entry, { expanded }, theme) =>
210
- entry.data
211
- ? liveAgentCard(
212
- () => entry.data!.content,
213
- () => entry.data!.details,
214
- expanded,
215
- theme,
216
- )
217
- : undefined,
218
- );
191
+ const hiddenResult: Component = { render: () => [], invalidate: () => undefined };
192
+ pi.registerMessageRenderer<AgentCommandDetails>(MESSAGE_TYPE, (message, _state, theme) => {
193
+ const details = message.details;
194
+ if (!details || details.agentId || details.inheritedContext !== undefined || details.ok)
195
+ return hiddenResult;
196
+ return new Text(theme.fg("error", `/${details.command}: ${details.error}`), 1, 0);
197
+ });
198
+ pi.registerEntryRenderer<AgentEntryData>(MESSAGE_TYPE, () => hiddenResult);
219
199
  // Pi's chat container does not pad renderer components; its own chat lines self-pad with paddingX 1.
220
200
  pi.registerEntryRenderer<DetachedEntryData>(DETACHED_ENTRY_TYPE, (entry, _state, theme) =>
221
201
  entry.data
222
202
  ? new Text(theme.fg("dim", `Detached session ${entry.data.sessionId}`), 1, 0)
223
203
  : undefined,
224
204
  );
205
+ pi.registerEntryRenderer<AttachedEntryData>(ATTACHED_ENTRY_TYPE, (entry, _state, theme) =>
206
+ entry.data
207
+ ? new Text(theme.fg("dim", `Attached session ${entry.data.sessionId}`), 1, 0)
208
+ : undefined,
209
+ );
225
210
  pi.registerEntryRenderer<RebasedEntryData>(REBASED_ENTRY_TYPE, (entry, _state, theme) =>
226
211
  entry.data ? new Text(theme.fg("dim", formatRebasedLine(entry.data)), 1, 0) : undefined,
227
212
  );
@@ -249,90 +234,3 @@ function formatTokenCount(tokens: number): string {
249
234
  return tokens < 1000 ? `${tokens}` : `${Math.round(tokens / 1000)}K`;
250
235
  }
251
236
 
252
- function liveAgentCard(
253
- content: () => string,
254
- details: () => AgentCommandDetails | undefined,
255
- expanded: boolean,
256
- theme: Theme,
257
- ): Component {
258
- const body = renderAgentCardBody(content(), details(), expanded, theme);
259
- return {
260
- render: (width) => renderAgentCard(content(), details(), body, theme).render(width),
261
- invalidate: () => body.invalidate(),
262
- };
263
- }
264
-
265
- function renderAgentCard(
266
- content: string,
267
- details: AgentCommandDetails | undefined,
268
- body: Component,
269
- theme: Theme,
270
- ): Container {
271
- const ok = details?.ok ?? !content.includes("<user_agent_error");
272
- const command = details ? `/${details.command}` : "/agent";
273
- const icon = ok ? theme.fg("success", "✓") : theme.fg("error", "✗");
274
- const statusText = ok ? theme.fg("dim", "completed") : theme.fg("error", "failed");
275
- const parts = buildRendererParts(content, details);
276
- const task = details?.task ?? extractTag(content, "task");
277
-
278
- let text = `${icon} ${theme.bold(command)} ${statusText}`;
279
- if (parts.length > 0) text += ` ${theme.fg("dim", "·")} ${theme.fg("dim", parts.join(" · "))}`;
280
- if (task) text += `\n ${theme.fg("dim", `task: ${truncatePlain(task, 88)}`)}`;
281
- const card = new Container();
282
- card.addChild(new Text(text, 1, 0));
283
- card.addChild(body);
284
- return card;
285
- }
286
-
287
- function renderAgentCardBody(
288
- content: string,
289
- details: AgentCommandDetails | undefined,
290
- expanded: boolean,
291
- theme: Theme,
292
- ): Component {
293
- const ok = details?.ok ?? !content.includes("<user_agent_error");
294
- const output =
295
- details?.responseText ??
296
- details?.error ??
297
- extractTag(content, ok ? "response" : "error") ??
298
- "No output.";
299
- if (expanded)
300
- return ok
301
- ? new Markdown(output, 3, 0, getMarkdownTheme())
302
- : new Text(theme.fg("error", output), 3, 0);
303
-
304
- const previewText = `⎿ ${truncatePlain(details?.responsePreview ?? output, 110)}`;
305
- if (!ok) return new Text(` ${theme.fg("error", previewText)}`, 1, 0);
306
- return new Markdown(previewText, 3, 0, getMarkdownTheme(), {
307
- color: (value) => theme.fg("dim", value),
308
- });
309
- }
310
-
311
- function customMessageContentText(content: unknown): string {
312
- if (typeof content === "string") return content;
313
- if (!Array.isArray(content)) return String(content ?? "");
314
- return content
315
- .filter(
316
- (part): part is { type: "text"; text: string } =>
317
- part?.type === "text" && typeof part.text === "string",
318
- )
319
- .map((part) => part.text)
320
- .join("\n");
321
- }
322
-
323
- function buildRendererParts(content: string, details: AgentCommandDetails | undefined): string[] {
324
- const parts: string[] = [];
325
- if (details?.modelLabel) parts.push(details.modelLabel);
326
- else if (details?.model) parts.push(details.model);
327
- if (details) parts.push(contextLabel(details.inheritedContext));
328
- const mainContext = mainContextLabel(details?.mainContextState);
329
- if (mainContext) parts.push(mainContext);
330
- if (details?.turnCount) parts.push(formatTurns(details.turnCount));
331
- if (details?.toolUses) parts.push(formatToolUses(details.toolUses));
332
- if (details?.durationMs !== undefined) parts.push(formatMs(details.durationMs));
333
- else {
334
- const duration = extractTag(content, "duration_ms");
335
- if (duration) parts.push(formatMs(Number(duration)));
336
- }
337
- return parts;
338
- }
package/widget.ts CHANGED
@@ -17,6 +17,7 @@ import {
17
17
  import { renderAgentContextMeter } from "./context-meter.js";
18
18
  import { selectRebaseMessages } from "./rebase.js";
19
19
  import type {
20
+ AgentMessage,
20
21
  AgentResultMessage,
21
22
  CompletedAgent,
22
23
  RebaseDelivery,
@@ -42,6 +43,15 @@ type UserAgentWidgetEntry =
42
43
  | { kind: "running"; startedAt: number; agent: RunningAgent }
43
44
  | { kind: "completed"; startedAt: number; agent: CompletedAgent };
44
45
 
46
+ type CachedActivityPreview = {
47
+ source: AgentMessage | string | undefined;
48
+ status: RunningAgent["status"] | boolean;
49
+ error: string | undefined;
50
+ width: number;
51
+ theme: Theme;
52
+ text: string;
53
+ };
54
+
45
55
  export class UserAgentWidget {
46
56
  private ui: UIContext | undefined;
47
57
  private frame = 0;
@@ -52,6 +62,7 @@ export class UserAgentWidget {
52
62
  private active = false;
53
63
  private selectedIndex = 0;
54
64
  private viewerOpen = false;
65
+ private activityPreviews = new WeakMap<ViewableAgent, CachedActivityPreview>();
55
66
  /** One confirmation slot for detaching the selected agent. */
56
67
  private readonly detachConfirmation = new TimedConfirmation<ViewableAgent>(() => this.update());
57
68
  private readonly completedAgents: CompletedAgent[] = [];
@@ -67,6 +78,7 @@ export class UserAgentWidget {
67
78
  if (ui === this.ui) return;
68
79
  this.inputUnsub?.();
69
80
  this.ui = ui;
81
+ this.activityPreviews = new WeakMap();
70
82
  this.widgetRegistered = false;
71
83
  this.tui = undefined;
72
84
  this.detachConfirmation.cancel();
@@ -101,12 +113,18 @@ export class UserAgentWidget {
101
113
  }
102
114
 
103
115
  ensureTimer(): void {
104
- if (!this.ui || this.interval) return;
105
- this.interval = setInterval(() => this.update(), 100);
116
+ const active = [...this.runningAgents].some(isLiveAgent);
117
+ if (!active && this.interval) {
118
+ clearInterval(this.interval);
119
+ this.interval = undefined;
120
+ }
121
+ if (!this.ui || !active || this.interval) return;
122
+ this.interval = setInterval(() => this.update(), 200);
106
123
  }
107
124
 
108
125
  update(): void {
109
126
  if (!this.ui) return;
127
+ this.ensureTimer();
110
128
  const entries = this.entries();
111
129
  if (entries.length === 0) {
112
130
  this.clear();
@@ -123,8 +141,7 @@ export class UserAgentWidget {
123
141
  return {
124
142
  render: (width: number) => this.renderWidget(width, theme),
125
143
  invalidate: () => {
126
- this.widgetRegistered = false;
127
- this.tui = undefined;
144
+ this.activityPreviews = new WeakMap();
128
145
  },
129
146
  };
130
147
  },
@@ -148,6 +165,7 @@ export class UserAgentWidget {
148
165
  this.widgetRegistered = false;
149
166
  this.tui = undefined;
150
167
  this.active = false;
168
+ this.activityPreviews = new WeakMap();
151
169
  this.detachConfirmation.cancel();
152
170
  }
153
171
 
@@ -266,6 +284,21 @@ export class UserAgentWidget {
266
284
  this.update();
267
285
  }
268
286
 
287
+ /** Session ids with a live row or completed card, matched by exact id or prefix. */
288
+ matchAttachedSessionIds(query: string): string[] {
289
+ return this.entries()
290
+ .map((entry) => entry.agent.sessionId)
291
+ .filter((sessionId) => sessionId.startsWith(query));
292
+ }
293
+
294
+ /** Open the overlay of the row holding this session, as if the user selected it. */
295
+ openViewer(sessionId: string): boolean {
296
+ const entry = this.entries().find((candidate) => candidate.agent.sessionId === sessionId);
297
+ if (!entry) return false;
298
+ this.openSelected(entry.agent);
299
+ return true;
300
+ }
301
+
269
302
  private clampSelection(): void {
270
303
  const total = this.entries().length;
271
304
  if (total === 0) {
@@ -568,16 +601,30 @@ export class UserAgentWidget {
568
601
  .join(" ");
569
602
  const activityPrefix = `${indent} ⎿ `;
570
603
  const activityWidth = Math.max(0, width - visibleWidth(activityPrefix));
604
+ const preview = this.renderActivityPreview(agent, activityWidth, theme);
605
+ const activityText = ok ? preview : theme.fg("error", preview);
606
+ const activity = `${theme.fg("dim", indent)} ${theme.fg("dim", "⎿ ")}${activityText}`;
607
+ return [truncateToWidth(header, width), truncateToWidth(activity, width)];
608
+ }
609
+
610
+ private renderActivityPreview(agent: ViewableAgent, width: number, theme: Theme): string {
611
+ const running = isRunningAgent(agent);
612
+ const source = isLiveAgent(agent) ? agent.latestFinalizedMessage : agent.responseText;
613
+ const status = running ? agent.status : agent.ok;
614
+ const cached = this.activityPreviews.get(agent);
615
+ if (
616
+ cached && cached.source === source && cached.status === status &&
617
+ cached.error === agent.error && cached.width === width && cached.theme === theme
618
+ ) return cached.text;
571
619
  const description = running
572
620
  ? describeActivity(agent)
573
621
  : {
574
622
  text: agent.ok ? agent.responseText : `Error: ${agent.error ?? "unknown"}`,
575
623
  truncation: "tail" as const,
576
624
  };
577
- const preview = renderActivity(description, activityWidth, theme);
578
- const activityText = ok ? preview : theme.fg("error", preview);
579
- const activity = `${theme.fg("dim", indent)} ${theme.fg("dim", "⎿ ")}${activityText}`;
580
- return [truncateToWidth(header, width), truncateToWidth(activity, width)];
625
+ const text = renderActivity(description, width, theme);
626
+ this.activityPreviews.set(agent, { source, status, error: agent.error, width, theme, text });
627
+ return text;
581
628
  }
582
629
  }
583
630