@giladbarnea/pi-user-agents 0.0.5 → 0.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/INTERNALS.md CHANGED
@@ -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
@@ -95,6 +95,8 @@ 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.**
@@ -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/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.6",
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(),
@@ -263,7 +264,7 @@ export function createRebaseDelivery(
263
264
  }
264
265
 
265
266
  /** Session inputs derived from forwarded pi CLI options, ready to hand to the child session. */
266
- type ChildResourceLoaderOptions = {
267
+ export type ChildResourceLoaderOptions = {
267
268
  systemPrompt?: string;
268
269
  appendSystemPrompt?: string[];
269
270
  additionalExtensionPaths?: string[];
@@ -276,14 +277,37 @@ type ChildResourceLoaderOptions = {
276
277
  noContextFiles?: boolean;
277
278
  };
278
279
 
279
- type ForwardedOptions = {
280
- model?: Model;
281
- thinkingLevel?: ThinkingLevel;
280
+ export type ChildToolOptions = {
282
281
  tools?: string[];
283
282
  excludeTools?: string[];
284
283
  noTools?: "all" | "builtin";
285
284
  };
286
285
 
286
+ type ForwardedOptions = ChildToolOptions & {
287
+ model?: Model;
288
+ thinkingLevel?: ThinkingLevel;
289
+ };
290
+
291
+ /** The cwd-bound services for one child session; the parent's ModelRuntime rides along (see INTERNALS). */
292
+ export function createChildServices(
293
+ ctx: ExtensionCommandContext,
294
+ resourceLoaderOptions?: ChildResourceLoaderOptions,
295
+ ): Promise<AgentSessionServices> {
296
+ const agentDir = getAgentDir();
297
+ const settingsManager = SettingsManager.create(ctx.cwd, agentDir, {
298
+ projectTrusted: ctx.isProjectTrusted(),
299
+ });
300
+ return createAgentSessionServices({
301
+ cwd: ctx.cwd,
302
+ agentDir,
303
+ settingsManager,
304
+ // The extension facade wraps the parent's runtime as a plain property; sharing it keeps
305
+ // extension-registered providers (their process-global dedup guard) available to the child.
306
+ modelRuntime: (ctx.modelRegistry as unknown as { runtime: ModelRuntime }).runtime,
307
+ resourceLoaderOptions,
308
+ });
309
+ }
310
+
287
311
  /** Parse forwarded pi CLI tokens once before model and session resolution. */
288
312
  export function parseForwardedArgs(forwardedArgs: string[]): Args {
289
313
  const parsed = parseArgs(forwardedArgs);
@@ -382,12 +406,17 @@ export function resolveForwardedOptions(
382
406
  }
383
407
  }
384
408
  if (parsed.thinking) forwarded.thinkingLevel = parsed.thinking;
409
+ return { ...forwarded, ...resolveToolOptions(parsed) };
410
+ }
385
411
 
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;
412
+ /** Map parsed pi tool options onto child-session inputs; dispatch and attach share this. */
413
+ export function resolveToolOptions(parsed: Args): ChildToolOptions {
414
+ const options: ChildToolOptions = {};
415
+ if (parsed.noTools) options.noTools = "all";
416
+ else if (parsed.noBuiltinTools) options.noTools = "builtin";
417
+ if (parsed.tools) options.tools = parsed.tools;
418
+ if (parsed.excludeTools) options.excludeTools = parsed.excludeTools;
419
+ return options;
391
420
  }
392
421
 
393
422
  export function buildChildResourceLoaderOptions(
@@ -449,29 +478,9 @@ async function runAgentLifecycle(
449
478
  widget,
450
479
  );
451
480
  unsubscribe = subscribeToChildSession(session, runningAgent, widget);
452
- await runChildTurns(pi, isShuttingDown, task, session, runningAgent, widget);
481
+ await runChildTurns(pi, isShuttingDown, DISPATCH_PREAMBLE + task, session, runningAgent, widget);
453
482
  } 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";
483
+ reportAgentFailure(pi, isShuttingDown, runningAgent, widget, error);
475
484
  } finally {
476
485
  unsubscribe?.();
477
486
  if (session) {
@@ -481,16 +490,51 @@ async function runAgentLifecycle(
481
490
  }
482
491
  }
483
492
 
484
- /** Run turns on an initialized child session until it posts, retires, or shuts down. */
493
+ /** Settle a failed lifecycle: record the error and post the error card, unless the user aborted. */
494
+ export function reportAgentFailure(
495
+ pi: ExtensionAPI,
496
+ isShuttingDown: () => boolean,
497
+ runningAgent: RunningAgent,
498
+ widget: UserAgentWidget,
499
+ error: unknown,
500
+ ): void {
501
+ if (runningAgent.aborted) {
502
+ logSteering(runningAgent.id, "agent-aborted");
503
+ return;
504
+ }
505
+ const message = errorMessage(error);
506
+ logSteering(runningAgent.id, "agent-error", { error: message });
507
+ runningAgent.status = "error";
508
+ runningAgent.completedAt = Date.now();
509
+ runningAgent.error = message;
510
+ if (runningAgent.notifyMainAgent && isShuttingDown()) {
511
+ runningAgent.mainContextState = "separate";
512
+ return;
513
+ }
514
+ const resultMessage = buildAgentResultMessage(
515
+ runningAgent,
516
+ { ok: false, error: message },
517
+ { display: runningAgent.notifyMainAgent },
518
+ );
519
+ widget.addCompleted(runningAgent, resultMessage, { squashable: !runningAgent.notifyMainAgent });
520
+ if (postUserAgentResult(pi, isShuttingDown, runningAgent.notifyMainAgent, resultMessage))
521
+ runningAgent.status = "posted";
522
+ }
523
+
524
+ /** Every dispatch prefixes its task with this; attach recognizes it as the child's dispatch boundary. */
525
+ export const DISPATCH_PREAMBLE =
526
+ "You are running in an ephemeral, forked background process now, concurrently with the main session. ";
527
+
528
+ /** Run turns on an initialized child session until it posts, retires, or shuts down. The initial instruction is sent verbatim. */
485
529
  export async function runChildTurns(
486
530
  pi: ExtensionAPI,
487
531
  isShuttingDown: () => boolean,
488
- task: string,
532
+ initialInstruction: string,
489
533
  session: AgentSession,
490
534
  runningAgent: RunningAgent,
491
535
  widget: UserAgentWidget,
492
536
  ): Promise<void> {
493
- let instruction = `You are running in an ephemeral, forked background process now, concurrently with the main session. ${task}`;
537
+ let instruction = initialInstruction;
494
538
  while (true) {
495
539
  const turnMessageStart = session.agent.state.messages.length;
496
540
  if (!runningAgent.interruptRequested) {
@@ -745,7 +789,7 @@ export function subscribeToChildSession(
745
789
  });
746
790
  }
747
791
 
748
- function assistantText(message: Extract<AgentMessage, { role: "assistant" }>): string {
792
+ export function assistantText(message: Extract<AgentMessage, { role: "assistant" }>): string {
749
793
  return message.content
750
794
  .filter((part) => part.type === "text")
751
795
  .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;
@@ -207,6 +221,8 @@ export class TimedConfirmation<Target> {
207
221
  export const MESSAGE_TYPE = "pi-user-agents";
208
222
  export const DETACHED_ENTRY_TYPE = "pi-user-agents-detached";
209
223
  export const REBASED_ENTRY_TYPE = "pi-user-agents-rebased";
224
+ export const ATTACHED_ENTRY_TYPE = "pi-user-agents-attached";
225
+ export const DISPATCH_ENTRY_TYPE = "pi-user-agents-dispatch";
210
226
  export const WIDGET_KEY = "pi-user-agents";
211
227
  export const STEERING_LOG_PATH = `${tmpdir()}/pi-user-agents-steer.log`;
212
228
 
package/transcript.ts CHANGED
@@ -6,6 +6,7 @@ import type {
6
6
  AgentEntryData,
7
7
  AgentMessage,
8
8
  AgentResultMessage,
9
+ AttachedEntryData,
9
10
  DetachedEntryData,
10
11
  ExtensionAPI,
11
12
  ExtensionCommandContext,
@@ -14,6 +15,7 @@ import type {
14
15
  Theme,
15
16
  } from "./shared.js";
16
17
  import {
18
+ ATTACHED_ENTRY_TYPE,
17
19
  contextLabel,
18
20
  DETACHED_ENTRY_TYPE,
19
21
  escapeAttribute,
@@ -44,7 +46,6 @@ export function reportCommandError(
44
46
  display: true,
45
47
  details: {
46
48
  command,
47
- inheritedContext: command === "agent",
48
49
  model: "",
49
50
  modelLabel: "",
50
51
  task: args.trim(),
@@ -222,6 +223,11 @@ export function registerUserAgentRenderer(pi: ExtensionAPI): void {
222
223
  ? new Text(theme.fg("dim", `Detached session ${entry.data.sessionId}`), 1, 0)
223
224
  : undefined,
224
225
  );
226
+ pi.registerEntryRenderer<AttachedEntryData>(ATTACHED_ENTRY_TYPE, (entry, _state, theme) =>
227
+ entry.data
228
+ ? new Text(theme.fg("dim", `Attached session ${entry.data.sessionId}`), 1, 0)
229
+ : undefined,
230
+ );
225
231
  pi.registerEntryRenderer<RebasedEntryData>(REBASED_ENTRY_TYPE, (entry, _state, theme) =>
226
232
  entry.data ? new Text(theme.fg("dim", formatRebasedLine(entry.data)), 1, 0) : undefined,
227
233
  );
@@ -324,7 +330,7 @@ function buildRendererParts(content: string, details: AgentCommandDetails | unde
324
330
  const parts: string[] = [];
325
331
  if (details?.modelLabel) parts.push(details.modelLabel);
326
332
  else if (details?.model) parts.push(details.model);
327
- if (details) parts.push(contextLabel(details.inheritedContext));
333
+ if (details?.inheritedContext !== undefined) parts.push(contextLabel(details.inheritedContext));
328
334
  const mainContext = mainContextLabel(details?.mainContextState);
329
335
  if (mainContext) parts.push(mainContext);
330
336
  if (details?.turnCount) parts.push(formatTurns(details.turnCount));
package/widget.ts CHANGED
@@ -266,6 +266,21 @@ export class UserAgentWidget {
266
266
  this.update();
267
267
  }
268
268
 
269
+ /** Session ids with a live row or completed card, matched by exact id or prefix. */
270
+ matchAttachedSessionIds(query: string): string[] {
271
+ return this.entries()
272
+ .map((entry) => entry.agent.sessionId)
273
+ .filter((sessionId) => sessionId.startsWith(query));
274
+ }
275
+
276
+ /** Open the overlay of the row holding this session, as if the user selected it. */
277
+ openViewer(sessionId: string): boolean {
278
+ const entry = this.entries().find((candidate) => candidate.agent.sessionId === sessionId);
279
+ if (!entry) return false;
280
+ this.openSelected(entry.agent);
281
+ return true;
282
+ }
283
+
269
284
  private clampSelection(): void {
270
285
  const total = this.entries().length;
271
286
  if (total === 0) {