@ryan_nookpi/pi-extension-subagent 0.1.0

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/commands.ts ADDED
@@ -0,0 +1,2295 @@
1
+ /** biome-ignore-all lint/suspicious/noExplicitAny: integrates with dynamic session entries, TUI callbacks, and unexported pi runtime shapes. */
2
+ /**
3
+ * Tool handler, slash-command handlers, and event handlers for the Subagent extension.
4
+ *
5
+ * All handlers receive the shared SubagentStore and ExtensionAPI as parameters
6
+ * instead of capturing closure variables — making dependencies explicit.
7
+ */
8
+
9
+ import * as fs from "node:fs";
10
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
11
+ import { Container, Key, matchesKey, Spacer, Text, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
12
+ import { discoverAgents } from "./agents.js";
13
+ import { loadSubagentConfig } from "./config.js";
14
+ import {
15
+ COMMAND_COMPLETION_LIMIT,
16
+ COMMAND_TASK_PREVIEW_CHARS,
17
+ CONTINUATION_OUTPUT_CONTEXT_MAX_CHARS,
18
+ DEFAULT_TURN_COUNT,
19
+ formatSymbolHints,
20
+ MS_PER_SECOND,
21
+ PARENT_ENTRY_TYPE,
22
+ RUN_OUTPUT_MESSAGE_MAX_CHARS,
23
+ RUN_TICK_INTERVAL_MS,
24
+ STALE_PENDING_COMPLETION_MS,
25
+ STATUS_LOG_FOOTER,
26
+ SUBVIEW_OVERLAY_MAX_HEIGHT,
27
+ SUBVIEW_OVERLAY_WIDTH,
28
+ } from "./constants.js";
29
+ import {
30
+ buildSubagentDisplayTaskFallback,
31
+ createDisplayTaskRefreshToken,
32
+ isDisplayTaskRefreshTokenCurrent,
33
+ shouldSummarizeSubagentTask,
34
+ summarizeSubagentDisplayTask,
35
+ } from "./display-task.js";
36
+ import { AGENT_NAME_PALETTE, agentBgIndex, formatUsageStats, truncateLines, truncatePlainToWidth } from "./format.js";
37
+ import {
38
+ clearPendingGroupCompletion,
39
+ consumePendingGroupCompletionsForSession,
40
+ evictStalePendingGroupCompletions,
41
+ upsertPendingGroupCompletion,
42
+ } from "./group-pending.js";
43
+ import { enqueueSubagentInvocation } from "./invocation-queue.js";
44
+ import { appendDisplayTaskUpdate, getSessionFileSize } from "./persisted-session.js";
45
+ import { readSessionReplayItems, SubagentSessionReplayOverlay } from "./replay.js";
46
+ import { invokeWithAutoRetry, MAX_SUBAGENT_AUTO_RETRIES } from "./retry.js";
47
+ import { getLatestRun, removeRun, trimCommandRunHistory } from "./run-utils.js";
48
+ import {
49
+ getFinalOutput,
50
+ getLastNonEmptyLine,
51
+ getSubCommandAgentCompletions,
52
+ matchSubCommandAgent,
53
+ runSingleAgent,
54
+ } from "./runner.js";
55
+ import { buildMainContextText, makeSubagentSessionFile, wrapTaskWithMainContext } from "./session.js";
56
+ import { type SubagentStore, truncateText, updateRunFromResult } from "./store.js";
57
+ import { createSubagentToolExecute } from "./tool-execute.js";
58
+ import { renderSubagentToolCall, renderSubagentToolResult } from "./tool-render.js";
59
+ import type { CommandRunState, SingleResult, SubagentDetails } from "./types.js";
60
+ import { ListAgentsParams, SubagentParams } from "./types.js";
61
+ import { updateCommandRunsWidget, type WidgetRenderCtx } from "./widget.js";
62
+
63
+ /**
64
+ * Capture switchSession from an ExtensionCommandContext into the shared store.
65
+ * Command handlers receive ExtensionCommandContext (which has switchSession),
66
+ * while input/event handlers only get ExtensionContext (no switchSession).
67
+ * This allows input handlers (<>, ><) to use the captured function as fallback.
68
+ */
69
+ function captureSwitchSession(store: SubagentStore, ctx: any): void {
70
+ if (typeof ctx?.switchSession === "function" && !store.switchSessionFn) {
71
+ store.switchSessionFn = ctx.switchSession.bind(ctx);
72
+ }
73
+ }
74
+
75
+ function refreshDisplayTaskInBackground(
76
+ store: SubagentStore,
77
+ runState: CommandRunState,
78
+ rawTask: string,
79
+ ctx: any,
80
+ ): void {
81
+ if (!shouldSummarizeSubagentTask(rawTask, runState.displayTask ?? "")) return;
82
+ if (!ctx?.model || typeof ctx?.modelRegistry?.getApiKeyAndHeaders !== "function") return;
83
+
84
+ const refreshToken = createDisplayTaskRefreshToken(runState);
85
+ void summarizeSubagentDisplayTask(rawTask, {
86
+ model: ctx.model,
87
+ modelRegistry: {
88
+ getApiKeyAndHeaders: ctx.modelRegistry.getApiKeyAndHeaders.bind(ctx.modelRegistry),
89
+ },
90
+ })
91
+ .then((displayTask) => {
92
+ if (!displayTask || runState.removed || store.disposed) return;
93
+ if (!isDisplayTaskRefreshTokenCurrent(runState, refreshToken)) return;
94
+ if (displayTask === runState.displayTask) return;
95
+ runState.displayTask = displayTask;
96
+ if (runState.deliveryMode !== "humanOnly") {
97
+ const originSessionFile = store.globalLiveRuns.get(runState.id)?.originSessionFile;
98
+ appendDisplayTaskUpdate(originSessionFile, {
99
+ runId: runState.id,
100
+ task: runState.task,
101
+ displayTask,
102
+ startedAt: runState.startedAt,
103
+ });
104
+ }
105
+ updateCommandRunsWidget(store);
106
+ })
107
+ .catch(() => {
108
+ // Ignore background summary failures.
109
+ });
110
+ }
111
+
112
+ // ─── SubagentHistoryOverlay ───────────────────────────────────────────────────
113
+
114
+ /**
115
+ * TUI overlay that lists all subagent runs (including removed) and lets the
116
+ * user inspect one.
117
+ *
118
+ * Keys: ↑↓ / j k navigate · Enter inspect · q / Esc close
119
+ */
120
+ class SubagentHistoryOverlay {
121
+ private selectedIndex = 0;
122
+ private scrollOffset = 0;
123
+
124
+ constructor(
125
+ private runs: CommandRunState[],
126
+ private onSelect: (run: CommandRunState) => void,
127
+ private onDone: () => void,
128
+ ) {}
129
+
130
+ private getViewport(): number {
131
+ const rows = Math.max(10, (process.stdout as any).rows || 24);
132
+ return Math.max(4, rows - 8);
133
+ }
134
+
135
+ private ensureVisible(): void {
136
+ const vp = this.getViewport();
137
+ if (this.selectedIndex < this.scrollOffset) {
138
+ this.scrollOffset = this.selectedIndex;
139
+ } else if (this.selectedIndex >= this.scrollOffset + vp) {
140
+ this.scrollOffset = this.selectedIndex - vp + 1;
141
+ }
142
+ }
143
+
144
+ handleInput(data: string, tui: any): void {
145
+ if (matchesKey(data, Key.up) || data === "k") {
146
+ this.selectedIndex = Math.max(0, this.selectedIndex - 1);
147
+ this.ensureVisible();
148
+ } else if (matchesKey(data, Key.down) || data === "j") {
149
+ this.selectedIndex = Math.min(this.runs.length - 1, this.selectedIndex + 1);
150
+ this.ensureVisible();
151
+ } else if (matchesKey(data, Key.enter)) {
152
+ const run = this.runs[this.selectedIndex];
153
+ if (run) this.onSelect(run);
154
+ return; // onSelect will close overlay
155
+ } else if (matchesKey(data, Key.escape) || data === "q" || data === "Q") {
156
+ this.onDone();
157
+ return;
158
+ }
159
+ tui.requestRender();
160
+ }
161
+
162
+ render(width: number, _height: number, theme: any): string[] {
163
+ const container = new Container();
164
+ const pad = " ";
165
+ const innerWidth = Math.max(20, width - 6);
166
+ const viewport = this.getViewport();
167
+ const total = this.runs.length;
168
+
169
+ this.ensureVisible();
170
+
171
+ const formatFooterLine = (helpText: string, rangeText: string) => {
172
+ const rangeWidth = visibleWidth(rangeText);
173
+ const gap = rangeWidth > 0 ? 2 : 0;
174
+ const helpWidth = Math.max(0, innerWidth - rangeWidth - gap);
175
+ const help = truncatePlainToWidth(helpText, helpWidth);
176
+ return `${theme.fg("dim", help)}${" ".repeat(gap)}${theme.fg("accent", rangeText)}`;
177
+ };
178
+
179
+ container.addChild(new Spacer(1));
180
+ container.addChild(
181
+ new Text(pad + theme.bold("Subagent Run History") + theme.fg("dim", ` (${total} total)`), 0, 0),
182
+ );
183
+ container.addChild(new Text(pad + theme.fg("muted", "─".repeat(Math.max(10, innerWidth))), 0, 0));
184
+
185
+ for (let row = 0; row < viewport; row++) {
186
+ const idx = this.scrollOffset + row;
187
+ const run = this.runs[idx];
188
+ if (!run) {
189
+ container.addChild(new Text("", 0, 0));
190
+ continue;
191
+ }
192
+
193
+ const isSelected = idx === this.selectedIndex;
194
+ const marker = isSelected ? "▸" : " ";
195
+
196
+ // Status color
197
+ let statusColor: "success" | "error" | "warning" | "dim" = "dim";
198
+ if (run.status === "done") statusColor = "success";
199
+ else if (run.status === "error") statusColor = "error";
200
+ else if (run.status === "running") statusColor = "warning";
201
+
202
+ const timeLabel = new Date(run.startedAt).toLocaleTimeString([], {
203
+ hour: "2-digit",
204
+ minute: "2-digit",
205
+ second: "2-digit",
206
+ });
207
+
208
+ const removedBadge = run.removed ? theme.fg("dim", " [removed]") : "";
209
+ const statusStr = theme.fg(statusColor, `[${run.status}]`);
210
+ const agentStr = theme.fg("accent", run.agent);
211
+ const taskPreview = run.task
212
+ .replace(/\s*\n+\s*/g, " ")
213
+ .replace(/\s{2,}/g, " ")
214
+ .trim()
215
+ .slice(0, COMMAND_TASK_PREVIEW_CHARS);
216
+
217
+ const fixed = `${marker} #${run.id} ${statusStr}${removedBadge} ${agentStr} ${theme.fg("dim", timeLabel)} `;
218
+ const taskWidth = Math.max(0, innerWidth - visibleWidth(fixed));
219
+ let line = `${fixed}${theme.fg("muted", truncatePlainToWidth(taskPreview, taskWidth))}`;
220
+
221
+ line = truncateToWidth(line, innerWidth, "");
222
+ if (run.removed) line = theme.fg("dim", line);
223
+ if (isSelected) line = theme.bg("selectedBg", line);
224
+
225
+ container.addChild(new Text(pad + line, 0, 0));
226
+ }
227
+
228
+ container.addChild(new Text(pad + theme.fg("muted", "─".repeat(Math.max(10, innerWidth))), 0, 0));
229
+
230
+ const listStart = total === 0 ? 0 : this.scrollOffset + 1;
231
+ const listEnd = Math.min(total, this.scrollOffset + viewport);
232
+ const range = `${listStart}-${listEnd}/${total}`;
233
+ container.addChild(new Text(pad + formatFooterLine("↑↓/jk navigate · Enter inspect · q/Esc close", range), 0, 0));
234
+ container.addChild(new Spacer(1));
235
+
236
+ return container.render(width);
237
+ }
238
+ }
239
+
240
+ class SubagentLastResponseOverlay {
241
+ private scrollOffset = 0;
242
+ private cachedWidth = -1;
243
+ private wrappedLines: string[] = [];
244
+
245
+ constructor(
246
+ private title: string,
247
+ private subtitle: string,
248
+ private content: string,
249
+ private onAttach: () => void,
250
+ private onDone: () => void,
251
+ ) {}
252
+
253
+ private getViewportRows(): number {
254
+ const rows = Math.max(18, (process.stdout as any).rows || 24);
255
+ return Math.max(6, rows - 10);
256
+ }
257
+
258
+ private getWrappedLines(width: number): string[] {
259
+ if (this.cachedWidth !== width) {
260
+ this.cachedWidth = width;
261
+ this.wrappedLines = new Text(this.content, 0, 0).render(width);
262
+ }
263
+ return this.wrappedLines;
264
+ }
265
+
266
+ handleInput(data: string, tui: any): void {
267
+ if (data === "a" || data === "A") {
268
+ this.onAttach();
269
+ this.onDone();
270
+ return;
271
+ }
272
+ if (matchesKey(data, Key.enter) || matchesKey(data, Key.escape) || data === "q" || data === "Q") {
273
+ this.onDone();
274
+ return;
275
+ }
276
+ const viewport = this.getViewportRows();
277
+ const maxOffset = Math.max(0, this.wrappedLines.length - viewport);
278
+ if (matchesKey(data, Key.up) || data === "k") {
279
+ this.scrollOffset = Math.max(0, this.scrollOffset - 1);
280
+ } else if (matchesKey(data, Key.down) || data === "j") {
281
+ this.scrollOffset = Math.min(maxOffset, this.scrollOffset + 1);
282
+ } else if (matchesKey(data, Key.pageUp)) {
283
+ this.scrollOffset = Math.max(0, this.scrollOffset - viewport);
284
+ } else if (matchesKey(data, Key.pageDown)) {
285
+ this.scrollOffset = Math.min(maxOffset, this.scrollOffset + viewport);
286
+ }
287
+ tui.requestRender();
288
+ }
289
+
290
+ render(width: number, _height: number, theme: any): string[] {
291
+ const container = new Container();
292
+ const pad = " ";
293
+ const boxWidth = Math.max(24, width - 10);
294
+ const contentWidth = Math.max(18, boxWidth - 4);
295
+ const viewport = this.getViewportRows();
296
+ const wrappedLines = this.getWrappedLines(contentWidth);
297
+ const maxOffset = Math.max(0, wrappedLines.length - viewport);
298
+ if (this.scrollOffset > maxOffset) this.scrollOffset = maxOffset;
299
+ const visibleLines = wrappedLines.slice(this.scrollOffset, this.scrollOffset + viewport);
300
+ const range =
301
+ wrappedLines.length > 0
302
+ ? `${this.scrollOffset + 1}-${Math.min(wrappedLines.length, this.scrollOffset + viewport)}/${wrappedLines.length}`
303
+ : "0/0";
304
+ const shadow = theme.fg("dim", "░");
305
+ const top = theme.fg("border", `┌${"─".repeat(boxWidth - 2)}┐`);
306
+ const separator = theme.fg("borderMuted", `├${"─".repeat(boxWidth - 2)}┤`);
307
+ const bottom = theme.fg("border", `└${"─".repeat(boxWidth - 2)}┘`);
308
+ const fill = (text: string) => {
309
+ const truncated = truncatePlainToWidth(text, contentWidth, "…");
310
+ return `${truncated}${" ".repeat(Math.max(0, contentWidth - visibleWidth(truncated)))}`;
311
+ };
312
+ const frame = (text: string, style: (value: string) => string = (value) => value) =>
313
+ `${theme.fg("border", "│")} ${style(fill(text))} ${theme.fg("border", "│")}`;
314
+
315
+ container.addChild(new Spacer(1));
316
+ container.addChild(new Text(`${pad + top} ${shadow}`, 0, 0));
317
+ container.addChild(new Text(`${pad + frame(this.title, (text) => theme.bold(text))} ${shadow}`, 0, 0));
318
+ container.addChild(new Text(`${pad + frame(this.subtitle, (text) => theme.fg("muted", text))} ${shadow}`, 0, 0));
319
+ container.addChild(new Text(`${pad + separator} ${shadow}`, 0, 0));
320
+ for (const line of visibleLines) {
321
+ container.addChild(new Text(`${pad + frame(line, (text) => theme.fg("toolOutput", text))} ${shadow}`, 0, 0));
322
+ }
323
+ if (visibleLines.length === 0) {
324
+ container.addChild(
325
+ new Text(`${pad + frame("(no response)", (text) => theme.fg("muted", text))} ${shadow}`, 0, 0),
326
+ );
327
+ }
328
+ container.addChild(new Text(`${pad + separator} ${shadow}`, 0, 0));
329
+ container.addChild(
330
+ new Text(
331
+ pad +
332
+ frame(`↑↓/jk scroll · a attach to editor · Enter/Esc/q close ${range}`, (text) => theme.fg("muted", text)) +
333
+ ` ${shadow}`,
334
+ 0,
335
+ 0,
336
+ ),
337
+ );
338
+ container.addChild(new Text(`${pad + bottom} ${shadow}`, 0, 0));
339
+ container.addChild(new Text(`${pad} ${theme.fg("dim", "░".repeat(boxWidth))}`, 0, 0));
340
+ container.addChild(new Spacer(1));
341
+
342
+ return container.render(width);
343
+ }
344
+ }
345
+
346
+ function getRunLatestResponse(run: CommandRunState): string {
347
+ const output = (run.lastOutput ?? "").trim();
348
+ if (output) return output;
349
+
350
+ if (run.sessionFile && fs.existsSync(run.sessionFile)) {
351
+ const replayItems = readSessionReplayItems(run.sessionFile);
352
+ for (let i = replayItems.length - 1; i >= 0; i--) {
353
+ const item = replayItems[i];
354
+ if (item?.type === "assistant" && item.content.trim()) return item.content.trim();
355
+ }
356
+ for (let i = replayItems.length - 1; i >= 0; i--) {
357
+ const item = replayItems[i];
358
+ if (item && item.type !== "user" && item.content.trim()) return item.content.trim();
359
+ }
360
+ }
361
+
362
+ if (run.status === "running") return "(still running; no final response yet)";
363
+ return run.lastLine?.trim() || "(no response captured)";
364
+ }
365
+
366
+ function attachRunResponseToEditor(ctx: ExtensionContext, run: CommandRunState, response: string): void {
367
+ const current = ctx.ui.getEditorText();
368
+ const separator = current.trim().length > 0 ? "\n\n" : "";
369
+ const attachment = `[subagent:${run.agent}#${run.id}]\n${response}`;
370
+ ctx.ui.setEditorText(`${current}${separator}${attachment}`);
371
+ ctx.ui.notify(`Attached subagent #${run.id} response to editor`, "info");
372
+ }
373
+
374
+ async function showRunLatestResponseOverlay(ctx: ExtensionContext, run: CommandRunState): Promise<void> {
375
+ const response = getRunLatestResponse(run);
376
+ const contextLabel = run.contextMode === "main" ? "main" : "sub";
377
+ const subtitle = `#${run.id} · ${run.agent} · ${run.status} · ctx:${contextLabel} · turn:${run.turnCount ?? DEFAULT_TURN_COUNT}`;
378
+
379
+ await ctx.ui.custom(
380
+ (tui, theme, _kb, done) => {
381
+ const overlay = new SubagentLastResponseOverlay(
382
+ `Subagent #${run.id} last response`,
383
+ subtitle,
384
+ response,
385
+ () => attachRunResponseToEditor(ctx, run, response),
386
+ () => done(undefined),
387
+ );
388
+ return {
389
+ render: (w) => overlay.render(w, 0, theme),
390
+ handleInput: (data) => overlay.handleInput(data, tui),
391
+ invalidate: () => {},
392
+ };
393
+ },
394
+ {
395
+ overlay: true,
396
+ overlayOptions: { width: SUBVIEW_OVERLAY_WIDTH, maxHeight: SUBVIEW_OVERLAY_MAX_HEIGHT, anchor: "center" },
397
+ },
398
+ );
399
+ }
400
+
401
+ // ─── subPeekHandler ──────────────────────────────────────────────────────────
402
+
403
+ async function subPeekHandler(args: string, ctx: ExtensionContext, store: SubagentStore): Promise<void> {
404
+ const raw = (args ?? "").trim();
405
+ let run: CommandRunState | undefined;
406
+ let runId: number | undefined;
407
+
408
+ if (!raw) {
409
+ run = getLatestRun(store);
410
+ runId = run?.id;
411
+ } else if (/^\d+$/.test(raw)) {
412
+ runId = Number(raw);
413
+ run = store.commandRuns.get(runId);
414
+ } else {
415
+ ctx.ui.notify("Usage: /sub:peek [runId] or <>7", "info");
416
+ return;
417
+ }
418
+
419
+ if (!run || runId === undefined) {
420
+ ctx.ui.notify(`Unknown subagent run${raw ? ` #${raw}` : ""}.`, "error");
421
+ return;
422
+ }
423
+
424
+ const response = getRunLatestResponse(run);
425
+ if (!ctx.hasUI) {
426
+ ctx.ui.notify(`Subagent #${run.id} last response\n\n${response}`, "info");
427
+ return;
428
+ }
429
+
430
+ await showRunLatestResponseOverlay(ctx, run);
431
+ }
432
+
433
+ /**
434
+ * Stage A: normalize a path — trim outer whitespace, strip CR/LF/TAB only.
435
+ * Preserves interior spaces (valid in macOS paths).
436
+ */
437
+ function normalizePath(raw: unknown): string | null {
438
+ if (!raw || typeof raw !== "string") return null;
439
+ const cleaned = raw.replace(/[\r\n\t]+/g, "").trim();
440
+ return cleaned || null;
441
+ }
442
+
443
+ function stripStatusLogFooter(text: string): string {
444
+ if (!text) return text;
445
+ const doubleBreakSuffix = `\n\n${STATUS_LOG_FOOTER}`;
446
+ if (text.endsWith(doubleBreakSuffix)) return text.slice(0, -doubleBreakSuffix.length);
447
+ const singleBreakSuffix = `\n${STATUS_LOG_FOOTER}`;
448
+ if (text.endsWith(singleBreakSuffix)) return text.slice(0, -singleBreakSuffix.length);
449
+ if (text.endsWith(STATUS_LOG_FOOTER)) return text.slice(0, -STATUS_LOG_FOOTER.length).trimEnd();
450
+ return text;
451
+ }
452
+
453
+ function toValidTimestampMs(value: unknown): number | undefined {
454
+ if (typeof value === "number" && Number.isFinite(value) && value > 0) {
455
+ return value;
456
+ }
457
+ if (typeof value === "string" && value.trim()) {
458
+ const parsed = Date.parse(value);
459
+ if (Number.isFinite(parsed) && parsed > 0) return parsed;
460
+ }
461
+ return undefined;
462
+ }
463
+
464
+ function toNonNegativeNumber(value: unknown): number | undefined {
465
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0) return undefined;
466
+ return value;
467
+ }
468
+
469
+ /**
470
+ * Clear commandRuns and restore from current session entries.
471
+ * Used by session_start handler (covers all session lifecycle reasons).
472
+ * Also restores `currentParentSessionFile` from the latest `subagent-parent` entry.
473
+ *
474
+ * Includes backward-compatible in-memory and pending-completion recovery for
475
+ * hosts that reuse one extension runtime. Standard Pi session replacement is
476
+ * handled by aborting active children during session_shutdown.
477
+ */
478
+ function restoreRunsFromSession(store: SubagentStore, ctx: any, pi?: ExtensionAPI): void {
479
+ let currentSessionFile: string | null = null;
480
+ try {
481
+ currentSessionFile = normalizePath(ctx.sessionManager.getSessionFile());
482
+ } catch {
483
+ currentSessionFile = null;
484
+ }
485
+
486
+ // Legacy fallback for hosts that reuse a runtime across session_start events.
487
+ if (store.currentSessionFile && store.currentSessionFile !== currentSessionFile) {
488
+ const snapshot = Array.from(store.commandRuns.values()).map((run) => ({ ...run }));
489
+ if (snapshot.length > 0) {
490
+ store.sessionRunCache.set(store.currentSessionFile, snapshot);
491
+ }
492
+ }
493
+ store.currentSessionFile = currentSessionFile;
494
+
495
+ store.commandRuns.clear();
496
+ store.commandWidgetCtx = ctx as unknown as WidgetRenderCtx;
497
+ let sawSubagentMarkers = false;
498
+
499
+ try {
500
+ const entries = ctx.sessionManager.getEntries();
501
+ const restoredRuns = new Map<number, CommandRunState>();
502
+ const removedRunIds = new Set<number>();
503
+ const displayTaskUpdates = new Map<number, { task?: string; displayTask?: string; startedAt?: number }>();
504
+ let maxRunId = 0;
505
+
506
+ // Restore parent link from latest subagent-parent entry (if any).
507
+ let latestParentSessionFile: string | null = null;
508
+ for (const entry of entries) {
509
+ if (entry.type === "custom") {
510
+ const ce = entry as any;
511
+ if (ce.customType === PARENT_ENTRY_TYPE) {
512
+ sawSubagentMarkers = true;
513
+ if (ce.data?.parentSessionFile) {
514
+ const cleaned = normalizePath(ce.data.parentSessionFile);
515
+ if (cleaned) latestParentSessionFile = cleaned;
516
+ }
517
+ }
518
+ }
519
+ }
520
+ store.currentParentSessionFile = latestParentSessionFile;
521
+
522
+ // First pass: collect removed run IDs and persisted displayTask updates
523
+ for (const entry of entries) {
524
+ if (entry.type === "custom") {
525
+ const ce = entry as any;
526
+ if (ce.customType === "subagent-removed") {
527
+ sawSubagentMarkers = true;
528
+ if (ce.data?.runId != null) {
529
+ removedRunIds.add(ce.data.runId);
530
+ }
531
+ continue;
532
+ }
533
+ if (ce.customType === "subagent-display-task" && typeof ce.data?.runId === "number") {
534
+ sawSubagentMarkers = true;
535
+ displayTaskUpdates.set(ce.data.runId, {
536
+ task: typeof ce.data?.task === "string" ? ce.data.task : undefined,
537
+ displayTask: typeof ce.data?.displayTask === "string" ? ce.data.displayTask : undefined,
538
+ startedAt: toValidTimestampMs(ce.data?.startedAt),
539
+ });
540
+ }
541
+ }
542
+ }
543
+
544
+ for (const entry of entries) {
545
+ if (entry.type !== "custom_message") continue;
546
+ const cm = entry as any;
547
+ if (cm.customType !== "subagent-command" && cm.customType !== "subagent-tool") continue;
548
+ sawSubagentMarkers = true;
549
+ const d = cm.details;
550
+ if (!d || typeof d.runId !== "number") continue;
551
+
552
+ const runId = d.runId;
553
+ if (runId > maxRunId) maxRunId = runId;
554
+
555
+ const existing = restoredRuns.get(runId);
556
+ const displayTaskUpdate = displayTaskUpdates.get(runId);
557
+ const entryTimestampMs = toValidTimestampMs((entry as any).timestamp);
558
+ const startedAtFromDetails = toValidTimestampMs(d.startedAt);
559
+ const elapsedFromDetails = toNonNegativeNumber(d.elapsedMs);
560
+ const lastActivityAtFromDetails = toValidTimestampMs(d.lastActivityAt);
561
+ const persistedSessionBaseOffset =
562
+ toNonNegativeNumber(d.persistedSessionBaseOffset) ?? existing?.persistedSessionBaseOffset;
563
+
564
+ // Determine final status primarily from structured metadata.
565
+ const content = typeof cm.content === "string" ? cm.content : "";
566
+ const statusRaw = typeof d.status === "string" ? d.status.trim().toLowerCase() : "";
567
+ const statusFromDetails: "done" | "error" | null =
568
+ statusRaw === "done" || statusRaw === "completed"
569
+ ? "done"
570
+ : statusRaw === "error" || statusRaw === "failed"
571
+ ? "error"
572
+ : null;
573
+ const statusFromExitCode: "done" | "error" | null =
574
+ typeof d.exitCode === "number" ? (d.exitCode === 0 ? "done" : "error") : null;
575
+ const statusFromErrorField: "done" | "error" | null =
576
+ typeof d.error === "string" && d.error.trim() ? "error" : null;
577
+
578
+ // Legacy fallback for old sessions where structured fields are missing.
579
+ const legacyStatusFromContent: "done" | "error" | null = content.includes("] completed")
580
+ ? "done"
581
+ : content.includes("] failed") || content.includes("] error")
582
+ ? "error"
583
+ : null;
584
+
585
+ const finalStatus = statusFromDetails ?? statusFromExitCode ?? statusFromErrorField ?? legacyStatusFromContent;
586
+
587
+ // Derive source from customType for backward-compatible restored run metadata.
588
+ const restoredSource: "tool" | "command" = cm.customType === "subagent-tool" ? "tool" : "command";
589
+
590
+ if (finalStatus) {
591
+ // Final message — create or overwrite with done/error state
592
+ const startedAt = startedAtFromDetails ?? existing?.startedAt ?? entryTimestampMs ?? Date.now();
593
+ const elapsedMs =
594
+ elapsedFromDetails ??
595
+ (existing?.elapsedMs && existing.elapsedMs > 0 ? existing.elapsedMs : undefined) ??
596
+ (entryTimestampMs !== undefined ? Math.max(0, entryTimestampMs - startedAt) : 0);
597
+ const lastActivityAt =
598
+ lastActivityAtFromDetails ?? entryTimestampMs ?? existing?.lastActivityAt ?? startedAt + elapsedMs;
599
+
600
+ const persistedDisplayTask =
601
+ displayTaskUpdate?.startedAt === startedAt ? displayTaskUpdate.displayTask : undefined;
602
+ const persistedDisplayTaskFallbackTask =
603
+ displayTaskUpdate?.startedAt === startedAt ? displayTaskUpdate.task : undefined;
604
+ const run: CommandRunState = {
605
+ id: runId,
606
+ agent: d.agent ?? existing?.agent ?? "unknown",
607
+ task: d.task ?? existing?.task ?? "",
608
+ displayTask:
609
+ persistedDisplayTask ??
610
+ d.displayTask ??
611
+ existing?.displayTask ??
612
+ persistedDisplayTaskFallbackTask ??
613
+ d.task ??
614
+ existing?.task ??
615
+ "",
616
+ status: finalStatus,
617
+ startedAt,
618
+ lastActivityAt,
619
+ elapsedMs,
620
+ toolCalls: existing?.toolCalls ?? 0,
621
+ lastLine: "",
622
+ lastOutput: "",
623
+ continuedFromRunId: d.continuedFromRunId,
624
+ turnCount: d.turnCount ?? existing?.turnCount ?? DEFAULT_TURN_COUNT,
625
+ sessionFile: d.sessionFile ?? existing?.sessionFile,
626
+ persistedSessionBaseOffset,
627
+ contextMode: d.contextMode ?? existing?.contextMode,
628
+ usage: d.usage ?? existing?.usage,
629
+ model: d.model ?? existing?.model,
630
+ thoughtText: d.thoughtText ?? d.progressText ?? existing?.thoughtText,
631
+ source: restoredSource,
632
+ runtime: d.runtime ?? existing?.runtime,
633
+ claudeSessionId: d.claudeSessionId ?? existing?.claudeSessionId,
634
+ claudeProjectDir: d.claudeProjectDir ?? existing?.claudeProjectDir,
635
+ };
636
+ // Extract thought/progress and output from content payload
637
+ const lines = content.split("\n");
638
+ if (!run.thoughtText) {
639
+ const thoughtLine = lines.find(
640
+ (l: string) => l.startsWith("Thought: ") || l.startsWith("Result: ") || l.startsWith("Progress: "),
641
+ );
642
+ if (thoughtLine) run.thoughtText = thoughtLine.replace(/^(Thought|Result|Progress): /, "").trim();
643
+ }
644
+ const bodyStart = lines.findIndex((l: string) => l === "") + 1;
645
+ if (bodyStart > 0 && bodyStart < lines.length) {
646
+ run.lastOutput = stripStatusLogFooter(lines.slice(bodyStart).join("\n"));
647
+ run.lastLine = getLastNonEmptyLine(run.lastOutput);
648
+ }
649
+ restoredRuns.set(runId, run);
650
+ } else {
651
+ // Started/resumed message — always update so we track the latest continuation.
652
+ // If a completion message follows, it will overwrite this.
653
+ // If not (crash/abort), this "interrupted" state persists.
654
+ const startedAt = startedAtFromDetails ?? entryTimestampMs ?? existing?.startedAt ?? Date.now();
655
+ const lastActivityAt = lastActivityAtFromDetails ?? entryTimestampMs ?? existing?.lastActivityAt ?? startedAt;
656
+
657
+ const persistedDisplayTask =
658
+ displayTaskUpdate?.startedAt === startedAt ? displayTaskUpdate.displayTask : undefined;
659
+ const persistedDisplayTaskFallbackTask =
660
+ displayTaskUpdate?.startedAt === startedAt ? displayTaskUpdate.task : undefined;
661
+ restoredRuns.set(runId, {
662
+ id: runId,
663
+ agent: d.agent ?? existing?.agent ?? "unknown",
664
+ task: d.task ?? existing?.task ?? "",
665
+ displayTask:
666
+ persistedDisplayTask ??
667
+ d.displayTask ??
668
+ existing?.displayTask ??
669
+ persistedDisplayTaskFallbackTask ??
670
+ d.task ??
671
+ existing?.task ??
672
+ "",
673
+ status: "error",
674
+ startedAt,
675
+ lastActivityAt,
676
+ elapsedMs: elapsedFromDetails ?? 0,
677
+ toolCalls: existing?.toolCalls ?? 0,
678
+ lastLine: "(interrupted — started but no completion found)",
679
+ lastOutput: existing?.lastOutput,
680
+ continuedFromRunId: d.continuedFromRunId,
681
+ turnCount: d.turnCount ?? existing?.turnCount ?? DEFAULT_TURN_COUNT,
682
+ sessionFile: d.sessionFile ?? existing?.sessionFile,
683
+ persistedSessionBaseOffset,
684
+ contextMode: d.contextMode ?? existing?.contextMode,
685
+ usage: existing?.usage,
686
+ model: existing?.model,
687
+ thoughtText: d.thoughtText ?? d.progressText ?? existing?.thoughtText,
688
+ source: restoredSource,
689
+ runtime: d.runtime ?? existing?.runtime,
690
+ claudeSessionId: d.claudeSessionId ?? existing?.claudeSessionId,
691
+ claudeProjectDir: d.claudeProjectDir ?? existing?.claudeProjectDir,
692
+ });
693
+ }
694
+ }
695
+
696
+ for (const [id, run] of restoredRuns) {
697
+ if (removedRunIds.has(id)) {
698
+ store.commandRuns.set(id, { ...run, removed: true }); // restore removed runs while preserving removed=true
699
+ continue;
700
+ }
701
+ store.commandRuns.set(id, run);
702
+ }
703
+ if (maxRunId >= store.nextCommandRunId) {
704
+ store.nextCommandRunId = maxRunId + 1;
705
+ }
706
+ } catch (_e) {
707
+ // Silently ignore restore errors — fresh state is fine
708
+ }
709
+
710
+ // ── Legacy live-run merge (origin session only) ────────────────────
711
+ const mergeSessionFile = currentSessionFile;
712
+ if (mergeSessionFile) {
713
+ for (const [runId, entry] of store.globalLiveRuns) {
714
+ if (entry.originSessionFile !== mergeSessionFile) continue;
715
+ if (!entry.runState.removed) {
716
+ store.commandRuns.set(runId, entry.runState);
717
+ }
718
+ }
719
+ }
720
+
721
+ // ── Deliver legacy pending completions ──────────────────────────────
722
+ if (pi && currentSessionFile) {
723
+ for (const [runId, entry] of store.globalLiveRuns) {
724
+ if (!entry.pendingCompletion) continue;
725
+ if (entry.originSessionFile === currentSessionFile) {
726
+ try {
727
+ pi.sendMessage(entry.pendingCompletion.message, entry.pendingCompletion.options);
728
+ store.commandRuns.set(runId, entry.runState);
729
+ store.globalLiveRuns.delete(runId);
730
+ } catch {
731
+ /* keep pending completion for later retry */
732
+ }
733
+ }
734
+ }
735
+
736
+ for (const [batchId, batch] of store.batchGroups) {
737
+ if (!batch.pendingCompletion) continue;
738
+ if (batch.originSessionFile !== currentSessionFile) continue;
739
+ try {
740
+ pi.sendMessage(batch.pendingCompletion.message, batch.pendingCompletion.options);
741
+ clearPendingGroupCompletion("batch", batchId);
742
+ for (const runId of batch.runIds) {
743
+ store.globalLiveRuns.delete(runId);
744
+ }
745
+ store.batchGroups.delete(batchId);
746
+ } catch {
747
+ upsertPendingGroupCompletion({
748
+ scope: "batch",
749
+ groupId: batchId,
750
+ originSessionFile: batch.originSessionFile,
751
+ runIds: batch.runIds,
752
+ pendingCompletion: batch.pendingCompletion,
753
+ });
754
+ }
755
+ }
756
+
757
+ for (const [pipelineId, pipeline] of store.pipelines) {
758
+ if (!pipeline.pendingCompletion) continue;
759
+ if (pipeline.originSessionFile !== currentSessionFile) continue;
760
+ try {
761
+ pi.sendMessage(pipeline.pendingCompletion.message, pipeline.pendingCompletion.options);
762
+ clearPendingGroupCompletion("chain", pipelineId);
763
+ for (const runId of pipeline.stepRunIds) {
764
+ store.globalLiveRuns.delete(runId);
765
+ }
766
+ store.pipelines.delete(pipelineId);
767
+ } catch {
768
+ upsertPendingGroupCompletion({
769
+ scope: "chain",
770
+ groupId: pipelineId,
771
+ originSessionFile: pipeline.originSessionFile,
772
+ runIds: pipeline.stepRunIds,
773
+ pendingCompletion: pipeline.pendingCompletion,
774
+ });
775
+ }
776
+ }
777
+
778
+ for (const pending of consumePendingGroupCompletionsForSession(currentSessionFile)) {
779
+ try {
780
+ pi.sendMessage(pending.pendingCompletion.message, pending.pendingCompletion.options);
781
+ } catch {
782
+ upsertPendingGroupCompletion(pending);
783
+ }
784
+ }
785
+ }
786
+
787
+ // ── Evict stale pending completions (memory leak guard) ─────────────
788
+ // If a completed run's pending completion has been sitting for longer
789
+ // than the threshold without the user returning to its origin session,
790
+ // discard it to prevent unbounded memory growth.
791
+ for (const [runId, entry] of store.globalLiveRuns) {
792
+ if (!entry.pendingCompletion) continue;
793
+ if (entry.runState.status === "running") continue;
794
+ const pendingSince = entry.pendingCompletion.createdAt ?? entry.runState.startedAt + entry.runState.elapsedMs;
795
+ if (Date.now() - pendingSince > STALE_PENDING_COMPLETION_MS) {
796
+ store.globalLiveRuns.delete(runId);
797
+ }
798
+ }
799
+
800
+ for (const [batchId, batch] of store.batchGroups) {
801
+ if (!batch.pendingCompletion) continue;
802
+ const pendingSince = batch.pendingCompletion.createdAt ?? batch.createdAt;
803
+ if (Date.now() - pendingSince > STALE_PENDING_COMPLETION_MS) {
804
+ clearPendingGroupCompletion("batch", batchId);
805
+ store.batchGroups.delete(batchId);
806
+ }
807
+ }
808
+
809
+ for (const [pipelineId, pipeline] of store.pipelines) {
810
+ if (!pipeline.pendingCompletion) continue;
811
+ const pendingSince = pipeline.pendingCompletion.createdAt ?? pipeline.createdAt;
812
+ if (Date.now() - pendingSince > STALE_PENDING_COMPLETION_MS) {
813
+ clearPendingGroupCompletion("chain", pipelineId);
814
+ store.pipelines.delete(pipelineId);
815
+ }
816
+ }
817
+
818
+ evictStalePendingGroupCompletions(STALE_PENDING_COMPLETION_MS);
819
+
820
+ // Fallback: if this session has no subagent markers at all, but we recently
821
+ // had in-memory runs for the same session file, reuse that snapshot so
822
+ // <> / >< hops do not make runs appear to "disappear".
823
+ if (store.commandRuns.size === 0 && !sawSubagentMarkers && currentSessionFile) {
824
+ const cached = store.sessionRunCache.get(currentSessionFile) ?? [];
825
+ for (const run of cached) {
826
+ store.commandRuns.set(run.id, { ...run });
827
+ }
828
+ }
829
+
830
+ // Refresh per-session snapshot with the latest reconstructed view.
831
+ if (currentSessionFile) {
832
+ const latestSnapshot = Array.from(store.commandRuns.values()).map((run) => ({ ...run }));
833
+ if (latestSnapshot.length > 0) {
834
+ store.sessionRunCache.set(currentSessionFile, latestSnapshot);
835
+ } else {
836
+ store.sessionRunCache.delete(currentSessionFile);
837
+ }
838
+ }
839
+
840
+ updateCommandRunsWidget(store, ctx as unknown as WidgetRenderCtx);
841
+ }
842
+
843
+ function deliverOrQueueCompletion(
844
+ store: SubagentStore,
845
+ pi: ExtensionAPI,
846
+ ctx: ExtensionContext,
847
+ runId: number,
848
+ runState: CommandRunState,
849
+ message: { customType: string; content: string; display: boolean; details: Record<string, unknown> },
850
+ ): void {
851
+ const options = { deliverAs: "followUp" as const };
852
+ const globalEntry = store.globalLiveRuns.get(runId);
853
+ let currentSessionFile: string | null = null;
854
+ try {
855
+ currentSessionFile = normalizePath(ctx.sessionManager.getSessionFile());
856
+ } catch {
857
+ /* ignore */
858
+ }
859
+
860
+ const inOriginSession =
861
+ !globalEntry ||
862
+ !currentSessionFile ||
863
+ !globalEntry.originSessionFile ||
864
+ currentSessionFile === globalEntry.originSessionFile;
865
+
866
+ if (inOriginSession) {
867
+ pi.sendMessage(message, options);
868
+ store.globalLiveRuns.delete(runId);
869
+ } else {
870
+ globalEntry.pendingCompletion = {
871
+ message,
872
+ options,
873
+ createdAt: Date.now(),
874
+ };
875
+ store.commandRuns.set(runId, runState);
876
+ }
877
+ }
878
+
879
+ function finalizeHumanOnlyCompletion(
880
+ store: SubagentStore,
881
+ ctx: ExtensionContext,
882
+ runId: number,
883
+ _title: string,
884
+ _content: string,
885
+ notifyMessage: string,
886
+ notifyLevel: "info" | "error" | "warning",
887
+ ): void {
888
+ ctx.ui.notify(notifyMessage, notifyLevel);
889
+ store.globalLiveRuns.delete(runId);
890
+ }
891
+
892
+ export function registerAll(pi: ExtensionAPI, store: SubagentStore): void {
893
+ pi.registerTool({
894
+ name: "list-agents",
895
+ label: "List Agents",
896
+ description:
897
+ "List available subagent definitions (name, source, model, thinking, tools, description). Useful before planning delegation.",
898
+ parameters: ListAgentsParams,
899
+ execute: async (_toolCallId, _params: Record<string, any>, _signal, _onUpdate, ctx) => {
900
+ const discovery = discoverAgents(ctx.cwd);
901
+ const agents = discovery.agents;
902
+
903
+ if (agents.length === 0) {
904
+ return {
905
+ content: [{ type: "text", text: "No subagents found." }],
906
+ details: {
907
+ projectAgentsDir: discovery.projectAgentsDir,
908
+ agents: [],
909
+ },
910
+ };
911
+ }
912
+
913
+ const lines = agents.map((agent) => {
914
+ const model = agent.model ?? "(inherit current model)";
915
+ const thinking = agent.thinking ?? "(inherit current thinking)";
916
+ const tools = agent.tools && agent.tools.length > 0 ? agent.tools.join(",") : "default";
917
+ const description = agent.description ? ` · ${agent.description}` : "";
918
+ return `${agent.name} [${agent.source}] · model: ${model} · thinking: ${thinking} · tools: ${tools}${description}`;
919
+ });
920
+
921
+ return {
922
+ content: [{ type: "text", text: `Available subagents\n\n${lines.join("\n")}` }],
923
+ details: {
924
+ projectAgentsDir: discovery.projectAgentsDir,
925
+ agents: agents.map((agent) => ({
926
+ name: agent.name,
927
+ source: agent.source,
928
+ model: agent.model,
929
+ thinking: agent.thinking,
930
+ tools: agent.tools ?? [],
931
+ description: agent.description,
932
+ })),
933
+ },
934
+ };
935
+ },
936
+ });
937
+
938
+ pi.registerTool({
939
+ name: "subagent",
940
+ label: "Subagent",
941
+ description:
942
+ "CLI-style subagent delegation. Use `subagent help` for commands. Async launches return later in TUI mode; do not poll immediately after launch.",
943
+ parameters: SubagentParams,
944
+
945
+ execute: createSubagentToolExecute(pi, store) as any,
946
+
947
+ renderCall: renderSubagentToolCall as any,
948
+
949
+ renderResult: renderSubagentToolResult as any,
950
+ });
951
+
952
+ const subCommand = {
953
+ description:
954
+ "Run a subagent in a dedicated sub-session: /sub:isolate <agent|alias> <task>, /sub:isolate <runId> <task>, /sub:isolate <task> (uses configured defaultAgent)",
955
+ getArgumentCompletions: (argumentPrefix: string) => {
956
+ const trimmedStart = argumentPrefix.trimStart();
957
+ if (trimmedStart.includes(" ")) return null;
958
+
959
+ const discovery = discoverAgents(process.cwd());
960
+ const agentItems = getSubCommandAgentCompletions(discovery.agents, argumentPrefix) ?? [];
961
+
962
+ const runItems = Array.from(store.commandRuns.values())
963
+ .sort((a, b) => b.id - a.id)
964
+ .filter((run) => !trimmedStart || run.id.toString().startsWith(trimmedStart))
965
+ .slice(0, COMMAND_COMPLETION_LIMIT)
966
+ .map((run) => ({
967
+ value: `${run.id} `,
968
+ label: `${run.id}`,
969
+ description: `continue ${run.agent}: ${truncateText(run.task, COMMAND_TASK_PREVIEW_CHARS)}`,
970
+ }));
971
+
972
+ const merged = [...runItems, ...agentItems];
973
+ return merged.length > 0 ? merged : null;
974
+ },
975
+ handler: async (
976
+ args: string,
977
+ ctx: ExtensionContext,
978
+ forceMainContextFromWrapper = false,
979
+ hiddenFromMain = false,
980
+ ) => {
981
+ captureSwitchSession(store, ctx);
982
+ const input = (args ?? "").trim();
983
+ const usageText =
984
+ "Usage: /sub:main <agent|alias> <task> | /sub:main <runId> <task> | /sub:main <task> | /sub:isolate <agent|alias> <task> | /sub:isolate <runId> <task> | /sub:isolate <task>";
985
+ let forceMainContext = forceMainContextFromWrapper;
986
+ const deliveryMode = hiddenFromMain ? "humanOnly" : "followUp";
987
+
988
+ if (input === "--main" || input.startsWith("--main ")) {
989
+ ctx.ui.notify(
990
+ "The '--main' prefix is not supported. Choose the context with /sub:main or /sub:isolate.",
991
+ "warning",
992
+ );
993
+ return;
994
+ }
995
+
996
+ if (!input) {
997
+ ctx.ui.notify(usageText, "info");
998
+ return;
999
+ }
1000
+
1001
+ if (hiddenFromMain && !ctx.hasUI) {
1002
+ pi.sendMessage(
1003
+ {
1004
+ customType: "subagent-command",
1005
+ content: "Hidden subagent mode requires interactive UI. Use /sub:isolate instead.",
1006
+ display: true,
1007
+ details: {},
1008
+ },
1009
+ { deliverAs: "followUp", triggerTurn: false },
1010
+ );
1011
+ return;
1012
+ }
1013
+
1014
+ const discovery = discoverAgents(ctx.cwd);
1015
+ const agents = discovery.agents;
1016
+
1017
+ if (agents.length === 0) {
1018
+ ctx.ui.notify(
1019
+ "No subagents found. Checked the configured agent directory plus project-local .pi/agents and .claude/agents.",
1020
+ "error",
1021
+ );
1022
+ return;
1023
+ }
1024
+
1025
+ const firstSpace = input.indexOf(" ");
1026
+ const firstToken = firstSpace === -1 ? input : input.slice(0, firstSpace);
1027
+ const continuationRun = /^\d+$/.test(firstToken) ? store.commandRuns.get(Number(firstToken)) : undefined;
1028
+
1029
+ let selectedAgent: string;
1030
+ let taskForDisplay: string;
1031
+ let taskForAgent: string;
1032
+ let continuedFromRunId: number | undefined;
1033
+ let sessionFileForRun: string | undefined;
1034
+
1035
+ if (continuationRun) {
1036
+ if (firstSpace === -1) {
1037
+ ctx.ui.notify(usageText, "info");
1038
+ return;
1039
+ }
1040
+
1041
+ const targetRunId = Number(firstToken);
1042
+ const targetRun = continuationRun;
1043
+
1044
+ if (targetRun.status === "running") {
1045
+ ctx.ui.notify(`Subagent #${targetRunId} is already running.`, "warning");
1046
+ return;
1047
+ }
1048
+
1049
+ if (targetRun.runtime === "claude") {
1050
+ if (!targetRun.claudeSessionId) {
1051
+ ctx.ui.notify(
1052
+ `Cannot resume Claude run #${targetRunId}: no claudeSessionId found. The session metadata was lost or never captured.`,
1053
+ "error",
1054
+ );
1055
+ return;
1056
+ }
1057
+ if (!targetRun.claudeProjectDir || targetRun.claudeProjectDir !== ctx.cwd) {
1058
+ ctx.ui.notify(
1059
+ `Cannot resume Claude run #${targetRunId}: claudeProjectDir mismatch. Expected "${targetRun.claudeProjectDir ?? "(none)"}", current cwd is "${ctx.cwd}".`,
1060
+ "error",
1061
+ );
1062
+ return;
1063
+ }
1064
+ }
1065
+
1066
+ const nextInstruction = input.slice(firstSpace + 1).trim();
1067
+ if (!nextInstruction) {
1068
+ ctx.ui.notify(usageText, "info");
1069
+ return;
1070
+ }
1071
+
1072
+ const previousAgentName = targetRun.agent;
1073
+ const directAgent = agents.find((agent) => agent.name.toLowerCase() === previousAgentName.toLowerCase());
1074
+ const fuzzyAgent = matchSubCommandAgent(agents, previousAgentName).matchedAgent;
1075
+ selectedAgent = directAgent?.name ?? fuzzyAgent?.name ?? previousAgentName;
1076
+
1077
+ if (!agents.some((agent) => agent.name === selectedAgent)) {
1078
+ ctx.ui.notify(
1079
+ `Run #${targetRunId} references unknown agent "${previousAgentName}". Use /sub:main <agent> <task> instead.`,
1080
+ "error",
1081
+ );
1082
+ return;
1083
+ }
1084
+
1085
+ taskForDisplay = `[continue #${targetRunId}] ${nextInstruction}`;
1086
+ continuedFromRunId = targetRunId;
1087
+ sessionFileForRun = targetRun.sessionFile;
1088
+
1089
+ if (sessionFileForRun) {
1090
+ // True continuation: reuse the same per-run session file.
1091
+ taskForAgent = nextInstruction;
1092
+ } else {
1093
+ // Fallback for older runs that were started in isolated/no-session mode.
1094
+ const previousOutputRaw = (targetRun.lastOutput ?? targetRun.lastLine ?? "").trim();
1095
+ const previousOutput =
1096
+ previousOutputRaw.length > CONTINUATION_OUTPUT_CONTEXT_MAX_CHARS
1097
+ ? `${previousOutputRaw.slice(0, CONTINUATION_OUTPUT_CONTEXT_MAX_CHARS)}\n... [truncated]`
1098
+ : previousOutputRaw;
1099
+
1100
+ taskForAgent = [
1101
+ `Continue subagent run #${targetRunId} using the same agent (${selectedAgent}).`,
1102
+ `Previous task:\n${targetRun.task}`,
1103
+ previousOutput ? `Previous output:\n${previousOutput}` : "Previous output: (not available)",
1104
+ `New instruction:\n${nextInstruction}`,
1105
+ ].join("\n\n");
1106
+ }
1107
+ } else {
1108
+ const { matchedAgent, ambiguousAgents } = matchSubCommandAgent(agents, firstToken);
1109
+ let resolvedAgent = matchedAgent;
1110
+
1111
+ if (ambiguousAgents.length > 1) {
1112
+ const names = ambiguousAgents.map((agent) => agent.name).join(", ");
1113
+
1114
+ if (firstSpace === -1) {
1115
+ ctx.ui.notify(`${usageText}. Ambiguous agent alias "${firstToken}": ${names}.`, "error");
1116
+ return;
1117
+ }
1118
+
1119
+ // NOTE(user-approved): preserve the existing notification behavior in no-UI mode.
1120
+ // (Improving the headless/RPC warning path is outside this change.)
1121
+ if (!ctx.hasUI) {
1122
+ ctx.ui.notify(
1123
+ `Ambiguous agent alias "${firstToken}": ${names}. Use a longer alias or exact name.`,
1124
+ "error",
1125
+ );
1126
+ return;
1127
+ }
1128
+
1129
+ const selectedName = await ctx.ui.select(
1130
+ `Ambiguous alias "${firstToken}" — choose subagent`,
1131
+ ambiguousAgents.map((agent) => agent.name),
1132
+ );
1133
+ if (!selectedName) {
1134
+ ctx.ui.notify("Subagent selection cancelled.", "info");
1135
+ return;
1136
+ }
1137
+
1138
+ resolvedAgent = ambiguousAgents.find((agent) => agent.name === selectedName);
1139
+ if (!resolvedAgent) {
1140
+ ctx.ui.notify("Could not resolve selected subagent.", "error");
1141
+ return;
1142
+ }
1143
+ }
1144
+
1145
+ if (resolvedAgent && firstSpace === -1) {
1146
+ ctx.ui.notify(usageText, "info");
1147
+ return;
1148
+ }
1149
+
1150
+ const defaultAgent = loadSubagentConfig(ctx.cwd).defaultAgent;
1151
+ selectedAgent = resolvedAgent?.name ?? defaultAgent;
1152
+ if (!resolvedAgent && !agents.some((agent) => agent.name === defaultAgent)) {
1153
+ const availableAgents = agents
1154
+ .map((agent) => agent.name)
1155
+ .sort()
1156
+ .join(", ");
1157
+ ctx.ui.notify(
1158
+ `Configured defaultAgent "${defaultAgent}" was not found. Available agents: ${availableAgents}. Set subagent.defaultAgent in the global settings or defaultAgent in .pi/subagent.json.`,
1159
+ "error",
1160
+ );
1161
+ return;
1162
+ }
1163
+ taskForDisplay = resolvedAgent ? input.slice(firstSpace + 1).trim() : input;
1164
+
1165
+ if (!taskForDisplay) {
1166
+ ctx.ui.notify(usageText, "info");
1167
+ return;
1168
+ }
1169
+
1170
+ taskForAgent = taskForDisplay;
1171
+ }
1172
+
1173
+ let runId: number;
1174
+ let runState: CommandRunState;
1175
+
1176
+ if (continuedFromRunId !== undefined) {
1177
+ const existingRun = store.commandRuns.get(continuedFromRunId);
1178
+ if (!existingRun) {
1179
+ ctx.ui.notify(`Unknown subagent run #${continuedFromRunId}.`, "error");
1180
+ return;
1181
+ }
1182
+
1183
+ runId = existingRun.id;
1184
+ runState = existingRun;
1185
+ runState.agent = selectedAgent;
1186
+ runState.task = taskForDisplay;
1187
+ runState.displayTask = buildSubagentDisplayTaskFallback(taskForDisplay);
1188
+ runState.status = "running";
1189
+ runState.startedAt = Date.now();
1190
+ runState.lastActivityAt = Date.now();
1191
+ runState.elapsedMs = 0;
1192
+ runState.toolCalls = 0;
1193
+ runState.lastLine = "";
1194
+ runState.lastOutput = "";
1195
+ runState.continuedFromRunId = continuedFromRunId;
1196
+ runState.usage = undefined;
1197
+ runState.model = undefined;
1198
+ runState.retryCount = 0;
1199
+ runState.lastRetryReason = undefined;
1200
+ runState.removed = false;
1201
+ runState.deliveryMode = deliveryMode;
1202
+ runState.turnCount = Math.max(DEFAULT_TURN_COUNT, runState.turnCount || DEFAULT_TURN_COUNT) + 1;
1203
+ // NOTE(user-approved): continuations preserve the existing context and session.
1204
+ // Switching between /sub:main and /sub:isolate does not retroactively affect existing runs.
1205
+ runState.contextMode = runState.contextMode ?? (forceMainContext ? "main" : "sub");
1206
+ runState.sessionFile = runState.sessionFile ?? sessionFileForRun ?? makeSubagentSessionFile(runId);
1207
+ runState.persistedSessionBaseOffset = getSessionFileSize(runState.sessionFile);
1208
+ sessionFileForRun = runState.sessionFile;
1209
+ } else {
1210
+ runId = store.nextCommandRunId++;
1211
+ if (forceMainContext) {
1212
+ // Extract main session context as text instead of copying the session file.
1213
+ // This prevents subagents from inheriting the main agent's persona.
1214
+ const subContextResult = buildMainContextText(ctx);
1215
+ const subContextText = typeof subContextResult === "string" ? subContextResult : subContextResult.text;
1216
+ const totalMessageCount = typeof subContextResult === "string" ? 0 : subContextResult.totalMessageCount;
1217
+ const rawMainSessionFile = ctx.sessionManager?.getSessionFile?.() ?? undefined;
1218
+ const mainSessionFile =
1219
+ typeof rawMainSessionFile === "string"
1220
+ ? rawMainSessionFile.replace(/[\r\n\t]+/g, "").trim() || undefined
1221
+ : undefined;
1222
+ if (subContextText || mainSessionFile) {
1223
+ taskForAgent = wrapTaskWithMainContext(taskForAgent, subContextText, {
1224
+ mainSessionFile,
1225
+ totalMessageCount,
1226
+ });
1227
+ } else {
1228
+ ctx.ui.notify(
1229
+ "Main session context is unavailable in this mode. Running with dedicated sub-session.",
1230
+ "warning",
1231
+ );
1232
+ forceMainContext = false;
1233
+ }
1234
+ sessionFileForRun = makeSubagentSessionFile(runId);
1235
+ } else {
1236
+ sessionFileForRun = makeSubagentSessionFile(runId);
1237
+ }
1238
+
1239
+ runState = {
1240
+ id: runId,
1241
+ agent: selectedAgent,
1242
+ task: taskForDisplay,
1243
+ displayTask: buildSubagentDisplayTaskFallback(taskForDisplay),
1244
+ status: "running",
1245
+ startedAt: Date.now(),
1246
+ lastActivityAt: Date.now(),
1247
+ elapsedMs: 0,
1248
+ toolCalls: 0,
1249
+ lastLine: "",
1250
+ lastOutput: "",
1251
+ continuedFromRunId,
1252
+ turnCount: DEFAULT_TURN_COUNT,
1253
+ sessionFile: sessionFileForRun,
1254
+ persistedSessionBaseOffset: getSessionFileSize(sessionFileForRun),
1255
+ removed: false,
1256
+ contextMode: forceMainContext ? "main" : "sub",
1257
+ retryCount: 0,
1258
+ deliveryMode,
1259
+ };
1260
+ store.commandRuns.set(runId, runState);
1261
+ }
1262
+
1263
+ const abortController = new AbortController();
1264
+ runState.abortController = abortController;
1265
+
1266
+ // Register for abort and completion handling within this session runtime.
1267
+ let originSessionFile = "";
1268
+ try {
1269
+ originSessionFile = normalizePath(ctx.sessionManager.getSessionFile()) ?? "";
1270
+ } catch {
1271
+ /* ignore */
1272
+ }
1273
+ store.globalLiveRuns.set(runId, {
1274
+ runState,
1275
+ abortController,
1276
+ originSessionFile,
1277
+ });
1278
+
1279
+ store.commandWidgetCtx = ctx as unknown as WidgetRenderCtx;
1280
+ updateCommandRunsWidget(store, ctx as unknown as WidgetRenderCtx);
1281
+ refreshDisplayTaskInBackground(store, runState, taskForDisplay, ctx);
1282
+
1283
+ const makeDetails = (results: SingleResult[]): SubagentDetails => ({
1284
+ mode: "single",
1285
+ inheritMainContext: runState.contextMode === "main",
1286
+ projectAgentsDir: discovery.projectAgentsDir,
1287
+ results,
1288
+ });
1289
+
1290
+ const contextLabel = hiddenFromMain
1291
+ ? "hidden sub-session"
1292
+ : runState.contextMode === "main"
1293
+ ? "main context"
1294
+ : "dedicated sub-session";
1295
+ const startedState = continuedFromRunId !== undefined ? "resumed" : "started";
1296
+
1297
+ if (!hiddenFromMain) {
1298
+ pi.sendMessage(
1299
+ {
1300
+ customType: "subagent-command",
1301
+ content:
1302
+ `[subagent:${selectedAgent}#${runId}] ${startedState}` +
1303
+ `\nContext: ${contextLabel} · turn ${runState.turnCount}` +
1304
+ ``,
1305
+ display: false,
1306
+ details: {
1307
+ runId,
1308
+ agent: selectedAgent,
1309
+ task: taskForDisplay,
1310
+ displayTask: runState.displayTask,
1311
+ continuedFromRunId,
1312
+ turnCount: runState.turnCount,
1313
+ contextMode: runState.contextMode,
1314
+ sessionFile: runState.sessionFile,
1315
+ persistedSessionBaseOffset: runState.persistedSessionBaseOffset,
1316
+ status: startedState,
1317
+ startedAt: runState.startedAt,
1318
+ elapsedMs: runState.elapsedMs,
1319
+ lastActivityAt: runState.lastActivityAt,
1320
+ thoughtText: runState.thoughtText,
1321
+ runtime: runState.runtime,
1322
+ claudeSessionId: runState.claudeSessionId,
1323
+ claudeProjectDir: runState.claudeProjectDir,
1324
+ },
1325
+ },
1326
+ { deliverAs: "followUp", triggerTurn: false },
1327
+ );
1328
+ }
1329
+
1330
+ ctx.ui.notify(
1331
+ `${
1332
+ continuedFromRunId !== undefined
1333
+ ? `Resumed subagent #${runId}: ${selectedAgent}`
1334
+ : `Started subagent #${runId}: ${selectedAgent}`
1335
+ } (${contextLabel} · turn ${runState.turnCount})`,
1336
+ "info",
1337
+ );
1338
+
1339
+ const tick = setInterval(() => {
1340
+ const current = store.commandRuns.get(runId);
1341
+ if (current?.status !== "running") {
1342
+ clearInterval(tick);
1343
+ return;
1344
+ }
1345
+ current.elapsedMs = Date.now() - current.startedAt;
1346
+ updateCommandRunsWidget(store);
1347
+ }, RUN_TICK_INTERVAL_MS);
1348
+
1349
+ let claudeCheckpointSent = !!runState.claudeSessionId;
1350
+ void (async () => {
1351
+ try {
1352
+ const { result, retryCount } = await invokeWithAutoRetry({
1353
+ maxRetries: MAX_SUBAGENT_AUTO_RETRIES,
1354
+ signal: abortController.signal,
1355
+ onRetryScheduled: ({ retryIndex, maxRetries, delayMs, reason }) => {
1356
+ runState.retryCount = retryIndex;
1357
+ runState.lastRetryReason = reason;
1358
+ runState.lastActivityAt = Date.now();
1359
+ runState.lastLine = `Auto-retrying ${retryIndex}/${maxRetries} in ${Math.ceil(delayMs / 1000)}s: ${reason}`;
1360
+ runState.lastOutput = runState.lastLine;
1361
+ updateCommandRunsWidget(store);
1362
+ ctx.ui.notify(`subagent #${runId} retry ${retryIndex}/${maxRetries}: ${reason}`, "warning");
1363
+ },
1364
+ invoke: () => {
1365
+ runState.persistedSessionBaseOffset = getSessionFileSize(runState.sessionFile);
1366
+ return enqueueSubagentInvocation(() =>
1367
+ runSingleAgent(
1368
+ ctx.cwd,
1369
+ agents,
1370
+ selectedAgent,
1371
+ taskForAgent,
1372
+ undefined,
1373
+ abortController.signal,
1374
+ (partial) => {
1375
+ if (runState.removed || store.disposed) return;
1376
+ const current = partial.details?.results?.[0];
1377
+ if (!current) return;
1378
+ updateRunFromResult(runState, current);
1379
+ if (!claudeCheckpointSent && runState.claudeSessionId) {
1380
+ claudeCheckpointSent = true;
1381
+ if (!hiddenFromMain) {
1382
+ pi.sendMessage(
1383
+ {
1384
+ customType: "subagent-command" as const,
1385
+ content: `[subagent:${selectedAgent}#${runId}] checkpoint`,
1386
+ display: false,
1387
+ details: {
1388
+ runId,
1389
+ agent: selectedAgent,
1390
+ task: taskForDisplay,
1391
+ displayTask: runState.displayTask,
1392
+ continuedFromRunId,
1393
+ turnCount: runState.turnCount,
1394
+ contextMode: runState.contextMode,
1395
+ sessionFile: runState.sessionFile,
1396
+ persistedSessionBaseOffset: runState.persistedSessionBaseOffset,
1397
+ status: "started",
1398
+ startedAt: runState.startedAt,
1399
+ elapsedMs: runState.elapsedMs,
1400
+ lastActivityAt: runState.lastActivityAt,
1401
+ runtime: runState.runtime,
1402
+ claudeSessionId: runState.claudeSessionId,
1403
+ claudeProjectDir: runState.claudeProjectDir,
1404
+ },
1405
+ },
1406
+ { deliverAs: "followUp", triggerTurn: false },
1407
+ );
1408
+ }
1409
+ }
1410
+ updateCommandRunsWidget(store);
1411
+ },
1412
+ makeDetails,
1413
+ {
1414
+ sessionFile: runState.sessionFile,
1415
+ resumeSessionId: runState.claudeSessionId,
1416
+ sidecarSessionFile: runState.sessionFile,
1417
+ persistedSessionBaseOffset: runState.persistedSessionBaseOffset,
1418
+ },
1419
+ ),
1420
+ );
1421
+ },
1422
+ });
1423
+ runState.retryCount = retryCount;
1424
+
1425
+ if (runState.removed || store.disposed) return;
1426
+
1427
+ updateRunFromResult(runState, result);
1428
+ const isError = result.exitCode !== 0 || result.stopReason === "error" || result.stopReason === "aborted";
1429
+ runState.status = isError ? "error" : "done";
1430
+ runState.elapsedMs = Date.now() - runState.startedAt;
1431
+ updateCommandRunsWidget(store);
1432
+
1433
+ const rawOutput = isError
1434
+ ? result.errorMessage || result.stderr || getFinalOutput(result.messages) || "(no output)"
1435
+ : getFinalOutput(result.messages) || "(no output)";
1436
+ const output =
1437
+ isError && rawOutput.length > RUN_OUTPUT_MESSAGE_MAX_CHARS
1438
+ ? `${rawOutput.slice(0, RUN_OUTPUT_MESSAGE_MAX_CHARS)}\n\n... [truncated]`
1439
+ : rawOutput;
1440
+ const usage = formatUsageStats(result.usage, result.model);
1441
+
1442
+ runState.lastOutput = rawOutput;
1443
+ if (rawOutput) runState.lastLine = getLastNonEmptyLine(rawOutput);
1444
+
1445
+ const completionMessage = {
1446
+ customType: "subagent-command" as const,
1447
+ content:
1448
+ `[subagent:${selectedAgent}#${runId}] ${isError ? "failed" : "completed"}` +
1449
+ `\nPrompt: ${truncateLines(taskForDisplay, 2)}` +
1450
+ (usage ? `\nUsage: ${usage}` : "") +
1451
+ (runState.retryCount ? `\nRetries: ${runState.retryCount}/${MAX_SUBAGENT_AUTO_RETRIES}` : "") +
1452
+ (runState.thoughtText ? `\nThought: ${runState.thoughtText}` : "") +
1453
+ `\n\n${output}`,
1454
+ display: true,
1455
+ details: {
1456
+ runId,
1457
+ agent: selectedAgent,
1458
+ task: taskForDisplay,
1459
+ displayTask: runState.displayTask,
1460
+ continuedFromRunId,
1461
+ turnCount: runState.turnCount,
1462
+ contextMode: runState.contextMode,
1463
+ sessionFile: runState.sessionFile,
1464
+ persistedSessionBaseOffset: runState.persistedSessionBaseOffset,
1465
+ startedAt: runState.startedAt,
1466
+ elapsedMs: runState.elapsedMs,
1467
+ lastActivityAt: runState.lastActivityAt,
1468
+ exitCode: result.exitCode,
1469
+ usage: result.usage,
1470
+ model: result.model,
1471
+ source: result.agentSource,
1472
+ thoughtText: runState.thoughtText,
1473
+ retryCount: runState.retryCount,
1474
+ status: runState.status,
1475
+ runtime: runState.runtime,
1476
+ claudeSessionId: runState.claudeSessionId,
1477
+ claudeProjectDir: runState.claudeProjectDir,
1478
+ },
1479
+ };
1480
+ if (hiddenFromMain) {
1481
+ finalizeHumanOnlyCompletion(
1482
+ store,
1483
+ ctx,
1484
+ runId,
1485
+ `Hidden subagent #${runId} · ${selectedAgent}`,
1486
+ completionMessage.content,
1487
+ isError
1488
+ ? `hidden subagent #${runId} (${selectedAgent}) failed`
1489
+ : `hidden subagent #${runId} (${selectedAgent}) completed`,
1490
+ isError ? "error" : "info",
1491
+ );
1492
+ } else {
1493
+ deliverOrQueueCompletion(store, pi, ctx, runId, runState, completionMessage);
1494
+ ctx.ui.notify(
1495
+ isError
1496
+ ? `subagent #${runId} (${selectedAgent}) failed`
1497
+ : `subagent #${runId} (${selectedAgent}) completed`,
1498
+ isError ? "error" : "info",
1499
+ );
1500
+ }
1501
+ } catch (error: any) {
1502
+ if (runState.removed || store.disposed) return;
1503
+ runState.status = "error";
1504
+ runState.elapsedMs = Date.now() - runState.startedAt;
1505
+ runState.lastLine =
1506
+ runState.autoAbortReason ?? (error?.message ? String(error.message) : "Subagent execution failed");
1507
+ runState.lastOutput = runState.lastLine;
1508
+
1509
+ const cmdErrorMessage = {
1510
+ customType: "subagent-command" as const,
1511
+ content:
1512
+ `[subagent:${selectedAgent}#${runId}] failed` +
1513
+ `\nPrompt: ${truncateLines(taskForDisplay, 2)}` +
1514
+ `\n\n${runState.lastLine}`,
1515
+ display: true,
1516
+ details: {
1517
+ runId,
1518
+ agent: selectedAgent,
1519
+ task: taskForDisplay,
1520
+ displayTask: runState.displayTask,
1521
+ continuedFromRunId,
1522
+ turnCount: runState.turnCount,
1523
+ contextMode: runState.contextMode,
1524
+ sessionFile: runState.sessionFile,
1525
+ persistedSessionBaseOffset: runState.persistedSessionBaseOffset,
1526
+ startedAt: runState.startedAt,
1527
+ elapsedMs: runState.elapsedMs,
1528
+ lastActivityAt: runState.lastActivityAt,
1529
+ error: runState.lastLine,
1530
+ thoughtText: runState.thoughtText,
1531
+ status: runState.status,
1532
+ runtime: runState.runtime,
1533
+ claudeSessionId: runState.claudeSessionId,
1534
+ claudeProjectDir: runState.claudeProjectDir,
1535
+ },
1536
+ };
1537
+ if (hiddenFromMain) {
1538
+ finalizeHumanOnlyCompletion(
1539
+ store,
1540
+ ctx,
1541
+ runId,
1542
+ `Hidden subagent #${runId} · ${selectedAgent}`,
1543
+ cmdErrorMessage.content,
1544
+ `hidden subagent #${runId} failed: ${runState.lastLine}`,
1545
+ "error",
1546
+ );
1547
+ } else {
1548
+ deliverOrQueueCompletion(store, pi, ctx, runId, runState, cmdErrorMessage);
1549
+ ctx.ui.notify(`subagent #${runId} failed: ${runState.lastLine}`, "error");
1550
+ }
1551
+ } finally {
1552
+ clearInterval(tick);
1553
+ runState.abortController = undefined;
1554
+ if (!store.disposed) {
1555
+ trimCommandRunHistory(store, {
1556
+ maxRuns: 10,
1557
+ ctx,
1558
+ pi,
1559
+ updateWidget: false,
1560
+ removalReason: "trim",
1561
+ });
1562
+ updateCommandRunsWidget(store);
1563
+ }
1564
+ }
1565
+ })();
1566
+ },
1567
+ };
1568
+
1569
+ pi.registerCommand("sub:isolate", subCommand);
1570
+
1571
+ pi.registerCommand("sub:main", {
1572
+ description: "Run a subagent with main-session context inheritance: /sub:main <agent|alias> <task>",
1573
+ getArgumentCompletions: subCommand.getArgumentCompletions,
1574
+ handler: async (args, ctx) => {
1575
+ captureSwitchSession(store, ctx);
1576
+ const forwarded = (args ?? "").trim();
1577
+ await subCommand.handler(forwarded, ctx, true);
1578
+ },
1579
+ });
1580
+
1581
+ pi.registerCommand("subagents", {
1582
+ description: "List available subagents and their model/thinking/tool settings",
1583
+ handler: async (_args, ctx) => {
1584
+ captureSwitchSession(store, ctx);
1585
+ const discovery = discoverAgents(ctx.cwd);
1586
+ const agents = discovery.agents;
1587
+ if (agents.length === 0) {
1588
+ ctx.ui.notify("No subagents found.", "warning");
1589
+ return;
1590
+ }
1591
+
1592
+ const lines = agents.map((a) => {
1593
+ const tools = a.tools?.join(",") ?? "default";
1594
+ const model = a.model ?? "(inherit current model)";
1595
+ const thinking = a.thinking ?? "(inherit current thinking)";
1596
+ const description = a.description ? ` · ${a.description}` : "";
1597
+ const colorCode = AGENT_NAME_PALETTE[agentBgIndex(a.name)];
1598
+ const coloredName = `\x1b[38;5;${colorCode}m${a.name}\x1b[39m`;
1599
+ return truncateText(
1600
+ `${coloredName} [${a.source}] · model: ${model} · thinking: ${thinking} · tools: ${tools}${description}`,
1601
+ 220,
1602
+ );
1603
+ });
1604
+
1605
+ ctx.ui.notify(`Available subagents\n${lines.map((line) => `• ${line}`).join("\n")}`, "info");
1606
+ },
1607
+ });
1608
+
1609
+ pi.registerCommand("sub:peek", {
1610
+ description: "Show the latest response from a subagent in an overlay: /sub:peek [runId]",
1611
+ getArgumentCompletions: (argumentPrefix) => {
1612
+ const trimmedStart = argumentPrefix.trimStart();
1613
+ if (trimmedStart.includes(" ")) return null;
1614
+
1615
+ const items = Array.from(store.commandRuns.values())
1616
+ .sort((a, b) => b.id - a.id)
1617
+ .filter((run) => !trimmedStart || run.id.toString().startsWith(trimmedStart))
1618
+ .slice(0, COMMAND_COMPLETION_LIMIT)
1619
+ .map((run) => ({
1620
+ value: `${run.id}`,
1621
+ label: `${run.id}`,
1622
+ description: `${run.status} ${run.agent}: ${truncateText(run.task, COMMAND_TASK_PREVIEW_CHARS)}`,
1623
+ }));
1624
+
1625
+ return items.length > 0 ? items : null;
1626
+ },
1627
+ handler: async (args, ctx) => {
1628
+ captureSwitchSession(store, ctx);
1629
+ await subPeekHandler(args, ctx, store);
1630
+ },
1631
+ });
1632
+
1633
+ pi.registerCommand("sub:open", {
1634
+ description: "Open a subagent session replay overlay: /sub:open [runId]",
1635
+ getArgumentCompletions: (argumentPrefix) => {
1636
+ const trimmedStart = argumentPrefix.trimStart();
1637
+ if (trimmedStart.includes(" ")) return null;
1638
+
1639
+ const items = Array.from(store.commandRuns.values())
1640
+ .sort((a, b) => b.id - a.id)
1641
+ .filter((run) => !trimmedStart || run.id.toString().startsWith(trimmedStart))
1642
+ .slice(0, COMMAND_COMPLETION_LIMIT)
1643
+ .map((run) => ({
1644
+ value: `${run.id}`,
1645
+ label: `${run.id}`,
1646
+ description: `${run.status} ${run.agent}: ${truncateText(run.task, COMMAND_TASK_PREVIEW_CHARS)}`,
1647
+ }));
1648
+
1649
+ return items.length > 0 ? items : null;
1650
+ },
1651
+ handler: async (args, ctx) => {
1652
+ captureSwitchSession(store, ctx);
1653
+ const raw = (args ?? "").trim();
1654
+ let id: number;
1655
+ let run: CommandRunState | undefined;
1656
+
1657
+ if (!raw) {
1658
+ run = getLatestRun(store);
1659
+ if (!run) {
1660
+ ctx.ui.notify("No subagent runs yet.", "info");
1661
+ return;
1662
+ }
1663
+ id = run.id;
1664
+ } else if (/^\d+$/.test(raw)) {
1665
+ id = Number(raw);
1666
+ run = store.commandRuns.get(id);
1667
+ } else {
1668
+ ctx.ui.notify("Usage: /sub:open [runId]", "info");
1669
+ return;
1670
+ }
1671
+ if (!run) {
1672
+ const availableRunIds = Array.from(store.commandRuns.keys()).sort((a, b) => a - b);
1673
+ const availableText =
1674
+ availableRunIds.length > 0
1675
+ ? `Available run IDs: ${availableRunIds.join(", ")}`
1676
+ : "No recent subagent runs available.";
1677
+ ctx.ui.notify(`Unknown subagent run #${id}. ${availableText}`, "error");
1678
+ return;
1679
+ }
1680
+
1681
+ const elapsedSec = Math.max(0, Math.round(run.elapsedMs / MS_PER_SECOND));
1682
+ const usageLine = run.usage ? `\nUsage: ${formatUsageStats(run.usage, run.model)}` : "";
1683
+ const output = (run.lastOutput ?? "").trim();
1684
+ const fallback =
1685
+ run.status === "running" ? "(still running; no final output yet)" : run.lastLine || "(no output captured)";
1686
+ const contextLabel = run.contextMode === "main" ? "main" : "isolated";
1687
+ const content =
1688
+ `Subagent #${run.id} [${run.status}] ${run.agent} ctx:${contextLabel} turn:${run.turnCount ?? DEFAULT_TURN_COUNT} ${elapsedSec}s tools:${run.toolCalls}` +
1689
+ `\n${run.task}` +
1690
+ usageLine +
1691
+ `\n\n${output || fallback}`;
1692
+
1693
+ if (!ctx.hasUI) {
1694
+ return;
1695
+ }
1696
+
1697
+ if (!run.sessionFile || !fs.existsSync(run.sessionFile)) {
1698
+ ctx.ui.notify(content, "info");
1699
+ return;
1700
+ }
1701
+
1702
+ const replayItems = readSessionReplayItems(run.sessionFile);
1703
+ if (replayItems.length === 0) {
1704
+ ctx.ui.notify(content, "info");
1705
+ return;
1706
+ }
1707
+
1708
+ await ctx.ui.custom(
1709
+ (tui, theme, _kb, done) => {
1710
+ const overlay = new SubagentSessionReplayOverlay(run, replayItems, () => done(undefined));
1711
+ return {
1712
+ render: (w) => overlay.render(w, 0 /* height computed internally */, theme),
1713
+ handleInput: (data) => overlay.handleInput(data, tui),
1714
+ invalidate: () => {},
1715
+ };
1716
+ },
1717
+ {
1718
+ overlay: true,
1719
+ overlayOptions: { width: SUBVIEW_OVERLAY_WIDTH, maxHeight: SUBVIEW_OVERLAY_MAX_HEIGHT, anchor: "center" },
1720
+ },
1721
+ );
1722
+ },
1723
+ });
1724
+
1725
+ pi.registerCommand("sub:history", {
1726
+ description: "Show all subagent run history (including removed) in an overlay: /sub:history",
1727
+ handler: async (_args, ctx) => {
1728
+ captureSwitchSession(store, ctx);
1729
+
1730
+ const allRuns = Array.from(store.commandRuns.values()).sort((a, b) => b.startedAt - a.startedAt);
1731
+
1732
+ if (allRuns.length === 0) {
1733
+ ctx.ui.notify("No subagent run history yet.", "info");
1734
+ return;
1735
+ }
1736
+
1737
+ if (!ctx.hasUI) {
1738
+ // Fallback: plain text list
1739
+ const lines = allRuns.map((r) => {
1740
+ const removed = r.removed ? " [removed]" : "";
1741
+ const task = r.task
1742
+ .replace(/\s*\n+\s*/g, " ")
1743
+ .trim()
1744
+ .slice(0, COMMAND_TASK_PREVIEW_CHARS);
1745
+ return `#${r.id} [${r.status}]${removed} ${r.agent}: ${task}`;
1746
+ });
1747
+ ctx.ui.notify(lines.join("\n"), "info");
1748
+ return;
1749
+ }
1750
+
1751
+ ctx.ui.setWidget("pixel-subagents", undefined);
1752
+
1753
+ await ctx.ui.custom(
1754
+ (tui, theme, _kb, done) => {
1755
+ const overlay = new SubagentHistoryOverlay(
1756
+ allRuns,
1757
+ async (run) => {
1758
+ done(undefined);
1759
+ await subPeekHandler(run.id.toString(), ctx, store);
1760
+ },
1761
+ () => done(undefined),
1762
+ );
1763
+ return {
1764
+ render: (w) => overlay.render(w, 0, theme),
1765
+ handleInput: (data) => overlay.handleInput(data, tui),
1766
+ invalidate: () => {},
1767
+ };
1768
+ },
1769
+ {
1770
+ overlay: true,
1771
+ overlayOptions: { width: SUBVIEW_OVERLAY_WIDTH, maxHeight: SUBVIEW_OVERLAY_MAX_HEIGHT, anchor: "center" },
1772
+ },
1773
+ );
1774
+ },
1775
+ });
1776
+
1777
+ pi.registerCommand("sub:rm", {
1778
+ description: "Remove one /sub job entry (aborts it if running): /sub:rm [runId]",
1779
+ handler: async (args, ctx) => {
1780
+ captureSwitchSession(store, ctx);
1781
+ const raw = (args ?? "").trim();
1782
+ let id: number;
1783
+ let run: CommandRunState | undefined;
1784
+
1785
+ if (!raw) {
1786
+ run = getLatestRun(store);
1787
+ if (!run) {
1788
+ ctx.ui.notify("No subagent runs to remove.", "info");
1789
+ return;
1790
+ }
1791
+ id = run.id;
1792
+ } else if (/^\d+$/.test(raw)) {
1793
+ id = Number(raw);
1794
+ run = store.commandRuns.get(id);
1795
+ } else {
1796
+ ctx.ui.notify("Usage: /sub:rm [runId]", "info");
1797
+ return;
1798
+ }
1799
+ if (!run) {
1800
+ ctx.ui.notify(`Unknown subagent run #${id}.`, "error");
1801
+ return;
1802
+ }
1803
+
1804
+ const { aborted } = removeRun(store, id, {
1805
+ ctx,
1806
+ pi,
1807
+ reason: "Aborting by /sub:rm...",
1808
+ removalReason: "sub-rm",
1809
+ });
1810
+ ctx.ui.notify(
1811
+ aborted ? `Removed subagent #${id} (aborting in background).` : `Removed subagent #${id}.`,
1812
+ aborted ? "warning" : "info",
1813
+ );
1814
+ },
1815
+ });
1816
+
1817
+ const handleSubClear = async (args: string, ctx: any) => {
1818
+ captureSwitchSession(store, ctx);
1819
+ const mode = (args ?? "").trim().toLowerCase();
1820
+ if (mode === "all") {
1821
+ let removed = 0;
1822
+ let aborted = 0;
1823
+ for (const id of Array.from(store.commandRuns.keys())) {
1824
+ const result = removeRun(store, id, {
1825
+ ctx,
1826
+ pi,
1827
+ updateWidget: false,
1828
+ reason: "Aborting by /sub:clear all...",
1829
+ removalReason: "sub-clear",
1830
+ });
1831
+ if (!result.removed) continue;
1832
+ removed++;
1833
+ if (result.aborted) aborted++;
1834
+ }
1835
+ updateCommandRunsWidget(store, ctx as unknown as WidgetRenderCtx);
1836
+ ctx.ui.notify(
1837
+ aborted > 0
1838
+ ? `Cleared ${removed} subagent job(s), aborting ${aborted} running job(s).`
1839
+ : `Cleared ${removed} subagent job(s).`,
1840
+ aborted > 0 ? "warning" : "info",
1841
+ );
1842
+ return;
1843
+ }
1844
+
1845
+ let removed = 0;
1846
+ for (const [id, run] of Array.from(store.commandRuns.entries())) {
1847
+ if (run.status === "running") continue;
1848
+ const result = removeRun(store, id, {
1849
+ ctx,
1850
+ pi,
1851
+ updateWidget: false,
1852
+ abortIfRunning: false,
1853
+ removalReason: "sub-clear",
1854
+ });
1855
+ if (result.removed) removed++;
1856
+ }
1857
+ updateCommandRunsWidget(store, ctx as unknown as WidgetRenderCtx);
1858
+ ctx.ui.notify(`Cleared ${removed} finished subagent job(s).`, "info");
1859
+ };
1860
+
1861
+ pi.registerCommand("sub:clear", {
1862
+ description: "Clear /sub job widget entries. /sub:clear (finished only) or /sub:clear all",
1863
+ handler: async (args, ctx) => {
1864
+ await handleSubClear(args, ctx);
1865
+ },
1866
+ });
1867
+
1868
+ const handleSubAbort = async (args: string, ctx: any) => {
1869
+ const raw = (args ?? "").trim().toLowerCase();
1870
+ const running = Array.from(store.commandRuns.values())
1871
+ .filter((run) => run.status === "running")
1872
+ .sort((a, b) => b.id - a.id);
1873
+
1874
+ if (running.length === 0) {
1875
+ ctx.ui.notify("No running subagent jobs.", "info");
1876
+ return;
1877
+ }
1878
+
1879
+ const abortRun = (run: CommandRunState): boolean => {
1880
+ // Try the run's own controller first, then fall back to globalLiveRuns
1881
+ // (the run's controller may have been cleared after a session switch).
1882
+ const controller = run.abortController ?? store.globalLiveRuns.get(run.id)?.abortController;
1883
+ if (!controller) return false;
1884
+ run.lastLine = "Aborting by user...";
1885
+ run.lastOutput = run.lastLine;
1886
+ controller.abort();
1887
+ return true;
1888
+ };
1889
+
1890
+ if (!raw) {
1891
+ const target = running[0];
1892
+ if (!abortRun(target)) {
1893
+ ctx.ui.notify(`Subagent #${target.id} is not abortable right now.`, "warning");
1894
+ return;
1895
+ }
1896
+ updateCommandRunsWidget(store, ctx as unknown as WidgetRenderCtx);
1897
+ ctx.ui.notify(`Aborting subagent #${target.id} (${target.agent})...`, "warning");
1898
+ return;
1899
+ }
1900
+
1901
+ if (raw === "all") {
1902
+ let count = 0;
1903
+ for (const run of running) {
1904
+ if (abortRun(run)) count++;
1905
+ }
1906
+ updateCommandRunsWidget(store, ctx as unknown as WidgetRenderCtx);
1907
+ ctx.ui.notify(
1908
+ count > 0 ? `Aborting ${count} running subagent job(s)...` : "No abortable subagent jobs.",
1909
+ count > 0 ? "warning" : "info",
1910
+ );
1911
+ return;
1912
+ }
1913
+
1914
+ if (/^\d+$/.test(raw)) {
1915
+ const id = Number(raw);
1916
+ const run = store.commandRuns.get(id);
1917
+ if (!run) {
1918
+ ctx.ui.notify(`Unknown subagent run #${id}.`, "error");
1919
+ return;
1920
+ }
1921
+ if (run.status !== "running") {
1922
+ ctx.ui.notify(`Subagent #${id} is not running.`, "info");
1923
+ return;
1924
+ }
1925
+ if (!abortRun(run)) {
1926
+ ctx.ui.notify(`Subagent #${id} is not abortable right now.`, "warning");
1927
+ return;
1928
+ }
1929
+ updateCommandRunsWidget(store, ctx as unknown as WidgetRenderCtx);
1930
+ ctx.ui.notify(`Aborting subagent #${id} (${run.agent})...`, "warning");
1931
+ return;
1932
+ }
1933
+
1934
+ ctx.ui.notify("Usage: /sub:abort [runId|all]", "info");
1935
+ };
1936
+
1937
+ pi.registerCommand("sub:abort", {
1938
+ description: "Abort running subagent job(s). /sub:abort [runId|all]",
1939
+ handler: async (args, ctx) => {
1940
+ captureSwitchSession(store, ctx);
1941
+ await handleSubAbort(args, ctx);
1942
+ },
1943
+ });
1944
+
1945
+ // Only actual keyboard shortcuts appear in the /hotkeys Extensions section.
1946
+ // NOTE: plain ">" is a real keybinding and hijacks editor text input,
1947
+ // so the hidden subagent prefix is documented via footer/status hints instead.
1948
+ pi.registerShortcut(">>" as any, {
1949
+ description: "Run subagent task",
1950
+ handler: async () => {
1951
+ // Documentation-only entry.
1952
+ },
1953
+ });
1954
+
1955
+ pi.on("input", async (event, ctx) => {
1956
+ if (event.source === "extension") {
1957
+ return { action: "continue" as const };
1958
+ }
1959
+
1960
+ const text = event.text ?? "";
1961
+ if (!text.startsWith(">") || text.startsWith(">>>")) return { action: "continue" as const };
1962
+
1963
+ const symbolMap = loadSubagentConfig(ctx.cwd).symbolMap;
1964
+ const hiddenSymbol = text.length > 1 && text[1] !== " " && text[1] !== "<" ? symbolMap[text[1]] : undefined;
1965
+ const isVisibleShortcut = text.startsWith(">>");
1966
+ const isHiddenShortcut = !isVisibleShortcut && (text === ">" || text.startsWith("> ") || Boolean(hiddenSymbol));
1967
+ if (!isVisibleShortcut && !isHiddenShortcut) {
1968
+ return { action: "continue" as const };
1969
+ }
1970
+
1971
+ const handleHiddenShortcut = async () => {
1972
+ const prefix = ">";
1973
+ if (!ctx.hasUI) {
1974
+ pi.sendMessage(
1975
+ {
1976
+ customType: "subagent-command",
1977
+ content: "Hidden subagent mode requires interactive UI. Use /sub:isolate instead.",
1978
+ display: true,
1979
+ details: {},
1980
+ },
1981
+ { deliverAs: "followUp", triggerTurn: false },
1982
+ );
1983
+ return;
1984
+ }
1985
+
1986
+ const symbolMap = loadSubagentConfig(ctx.cwd).symbolMap;
1987
+ const forwardedArgs = text.slice(prefix.length).trim();
1988
+ if (!forwardedArgs) {
1989
+ ctx.ui.notify(
1990
+ `${prefix} [agent] <task> | ${prefix} <runId> <task> | ${prefix}<symbol> <task>\n${formatSymbolHints(symbolMap, prefix)}`,
1991
+ "info",
1992
+ );
1993
+ return;
1994
+ }
1995
+
1996
+ const dedicatedSymbol = symbolMap[forwardedArgs[0]];
1997
+ if (dedicatedSymbol) {
1998
+ const task = forwardedArgs.slice(1).trim();
1999
+ if (!task) {
2000
+ ctx.ui.notify(formatSymbolHints(symbolMap, prefix), "info");
2001
+ return;
2002
+ }
2003
+ await subCommand.handler(`${dedicatedSymbol} ${task}`, ctx, true, true);
2004
+ return;
2005
+ }
2006
+
2007
+ const firstSpace = forwardedArgs.indexOf(" ");
2008
+ const firstToken = firstSpace === -1 ? forwardedArgs : forwardedArgs.slice(0, firstSpace);
2009
+ if (/^\d+$/.test(firstToken) && !store.commandRuns.has(Number(firstToken))) {
2010
+ ctx.ui.notify(`Unknown subagent run #${firstToken}.`, "error");
2011
+ return;
2012
+ }
2013
+ await subCommand.handler(forwardedArgs, ctx, true, true);
2014
+ };
2015
+
2016
+ if (isHiddenShortcut) {
2017
+ await handleHiddenShortcut();
2018
+ return { action: "handled" as const };
2019
+ }
2020
+
2021
+ // ── Configured symbol shortcut: >><symbol> task ──
2022
+ if (text.length >= 3) {
2023
+ const symbolChar = text[2];
2024
+ const symbolAgent = symbolChar !== " " ? symbolMap[symbolChar] : undefined;
2025
+ if (symbolAgent) {
2026
+ const task = text.slice(3).trim();
2027
+ if (!task) {
2028
+ ctx.ui.notify(formatSymbolHints(symbolMap), "info");
2029
+ return { action: "handled" as const };
2030
+ }
2031
+ await subCommand.handler(`${symbolAgent} ${task}`, ctx, true);
2032
+ return { action: "handled" as const };
2033
+ }
2034
+ }
2035
+
2036
+ // ── Original >> <args> pattern ──
2037
+ if (text[2] !== " ") {
2038
+ return { action: "continue" as const };
2039
+ }
2040
+
2041
+ const forwardedArgs = text.slice(3).trim();
2042
+ if (!forwardedArgs) {
2043
+ const symbolHelp = formatSymbolHints(symbolMap);
2044
+ ctx.ui.notify(
2045
+ `>> [agent] <task> | >> <runId> <task>${symbolHelp ? ` | >><symbol> <task>\n${symbolHelp}` : ""}`,
2046
+ "info",
2047
+ );
2048
+ return { action: "handled" as const };
2049
+ }
2050
+
2051
+ const firstSpace = forwardedArgs.indexOf(" ");
2052
+ const firstToken = firstSpace === -1 ? forwardedArgs : forwardedArgs.slice(0, firstSpace);
2053
+ if (/^\d+$/.test(firstToken) && !store.commandRuns.has(Number(firstToken))) {
2054
+ ctx.ui.notify(`Unknown subagent run #${firstToken}.`, "error");
2055
+ return { action: "handled" as const };
2056
+ }
2057
+
2058
+ await subCommand.handler(forwardedArgs, ctx, true);
2059
+ return { action: "handled" as const };
2060
+ });
2061
+
2062
+ // #<runId> shortcut: resume a subagent run (e.g. #42 keep going)
2063
+ pi.registerShortcut("#<runId>" as any, {
2064
+ description: "Resume subagent run: #<runId> <task>",
2065
+ handler: async () => {
2066
+ // Documentation-only entry.
2067
+ },
2068
+ });
2069
+
2070
+ pi.on("input", async (event, ctx) => {
2071
+ if (event.source === "extension") {
2072
+ return { action: "continue" as const };
2073
+ }
2074
+
2075
+ const text = event.text ?? "";
2076
+
2077
+ // Match #<digits> pattern (e.g. #42 task, #7 keep going)
2078
+ const match = /^#(\d+)\s(.+)/.exec(text);
2079
+ if (!match) {
2080
+ return { action: "continue" as const };
2081
+ }
2082
+
2083
+ const runId = match[1];
2084
+ const task = match[2].trim();
2085
+
2086
+ if (!task) {
2087
+ ctx.ui.notify("Usage: #<runId> <task>", "info");
2088
+ return { action: "handled" as const };
2089
+ }
2090
+
2091
+ if (!store.commandRuns.has(Number(runId))) {
2092
+ ctx.ui.notify(`Unknown subagent run #${runId}.`, "error");
2093
+ return { action: "handled" as const };
2094
+ }
2095
+
2096
+ await subCommand.handler(`${runId} ${task}`, ctx, true);
2097
+ return { action: "handled" as const };
2098
+ });
2099
+
2100
+ pi.on("input", async (event, ctx) => {
2101
+ if (event.source === "extension") {
2102
+ return { action: "continue" as const };
2103
+ }
2104
+
2105
+ const text = event.text ?? "";
2106
+ const compactPeekMatch = /^<>(\d+)$/.exec(text.trim());
2107
+ if (!compactPeekMatch?.[1]) {
2108
+ return { action: "continue" as const };
2109
+ }
2110
+
2111
+ await subPeekHandler(compactPeekMatch[1], ctx, store);
2112
+ return { action: "handled" as const };
2113
+ });
2114
+
2115
+ // << shortcut: abort running jobs or clear finished jobs
2116
+ pi.registerShortcut("<<" as any, {
2117
+ description: "Abort or clear subagent runs",
2118
+ handler: async () => {
2119
+ // Documentation-only entry.
2120
+ },
2121
+ });
2122
+
2123
+ pi.registerShortcut("<<<" as any, {
2124
+ description: "Clear finished subagent jobs (= /sub:clear). <<< all to clear all",
2125
+ handler: async () => {
2126
+ // Documentation-only entry.
2127
+ },
2128
+ });
2129
+
2130
+ pi.on("input", async (event, ctx) => {
2131
+ if (event.source === "extension") {
2132
+ return { action: "continue" as const };
2133
+ }
2134
+
2135
+ const text = event.text ?? "";
2136
+ if (!text.startsWith("<<")) {
2137
+ return { action: "continue" as const };
2138
+ }
2139
+
2140
+ // ── <<< shortcut: clear finished jobs (same as /sub:clear) ──
2141
+ // Must be matched before << patterns.
2142
+ if (text.startsWith("<<<")) {
2143
+ const clearArgs = text.slice(3).trim();
2144
+ await handleSubClear(clearArgs, ctx);
2145
+ return { action: "handled" as const };
2146
+ }
2147
+
2148
+ const raw = text.slice(2).trim();
2149
+
2150
+ // << 1,2,3 — multiple run IDs (comma-separated)
2151
+ // << 1 — single run ID
2152
+ // << (no args) — latest running run only
2153
+ const ids = raw
2154
+ ? raw
2155
+ .split(",")
2156
+ .map((s) => s.trim())
2157
+ .filter(Boolean)
2158
+ : [];
2159
+
2160
+ if (ids.length === 0) {
2161
+ // No args: abort latest running job only.
2162
+ // Never auto-clear finished runs — too dangerous on accidental <<.
2163
+ const running = Array.from(store.commandRuns.values())
2164
+ .filter((r) => r.status === "running")
2165
+ .sort((a, b) => b.id - a.id);
2166
+ if (running.length > 0) {
2167
+ await handleSubAbort("", ctx);
2168
+ } else {
2169
+ ctx.ui.notify("No running jobs. Use << <id> or /sub:clear.", "info");
2170
+ }
2171
+ return { action: "handled" as const };
2172
+ }
2173
+
2174
+ // Validate all IDs are numeric
2175
+ if (!ids.every((id) => /^\d+$/.test(id))) {
2176
+ ctx.ui.notify("Usage: << [runId,runId,...]", "info");
2177
+ return { action: "handled" as const };
2178
+ }
2179
+
2180
+ let aborted = 0;
2181
+ let cleared = 0;
2182
+ const unknown: string[] = [];
2183
+ for (const idStr of ids) {
2184
+ const id = Number(idStr);
2185
+ const run = store.commandRuns.get(id);
2186
+ if (!run) {
2187
+ unknown.push(idStr);
2188
+ continue;
2189
+ }
2190
+ const shortcutController = run.abortController ?? store.globalLiveRuns.get(id)?.abortController;
2191
+ if (run.status === "running" && shortcutController) {
2192
+ run.lastLine = "Aborting by user...";
2193
+ run.lastOutput = run.lastLine;
2194
+ shortcutController.abort();
2195
+ aborted++;
2196
+ } else if (run.status !== "running") {
2197
+ const result = removeRun(store, id, {
2198
+ ctx,
2199
+ pi,
2200
+ updateWidget: false,
2201
+ abortIfRunning: false,
2202
+ removalReason: "shortcut-clear",
2203
+ });
2204
+ if (result.removed) cleared++;
2205
+ }
2206
+ }
2207
+ updateCommandRunsWidget(store, ctx as unknown as WidgetRenderCtx);
2208
+
2209
+ const parts: string[] = [];
2210
+ if (aborted) parts.push(`${aborted} aborted`);
2211
+ if (cleared) parts.push(`${cleared} cleared`);
2212
+ if (unknown.length) parts.push(`#${unknown.join(",#")} not found`);
2213
+ ctx.ui.notify(parts.join(", ") || "Nothing to do.", parts.length ? (aborted ? "warning" : "info") : "info");
2214
+ return { action: "handled" as const };
2215
+ });
2216
+
2217
+ // ── onTerminalInput hack: auto-redirect <>7 to /sub:peek 7 ───────────
2218
+ let unsubTerminalInput: (() => void) | null = null;
2219
+
2220
+ function registerTerminalInputRedirect(ctx: any): void {
2221
+ // Unsubscribe previous listener to avoid duplicates on session change.
2222
+ unsubTerminalInput?.();
2223
+ unsubTerminalInput = null;
2224
+
2225
+ unsubTerminalInput = ctx.ui.onTerminalInput((data: string) => {
2226
+ // Only intercept Enter key (all terminal variants).
2227
+ if (!matchesKey(data, "enter")) return undefined;
2228
+
2229
+ const editorText = (ctx.ui.getEditorText() ?? "").trim();
2230
+
2231
+ // <>7 → /sub:peek 7
2232
+ const compactPeekMatch = /^<>(\d+)$/.exec(editorText);
2233
+ if (compactPeekMatch?.[1]) {
2234
+ ctx.ui.setEditorText(`/sub:peek ${compactPeekMatch[1]}`);
2235
+ return undefined; // let Enter proceed with rewritten text
2236
+ }
2237
+
2238
+ return undefined;
2239
+ });
2240
+ }
2241
+
2242
+ // ── Persona injection for sub-trans child sessions ──────────────────
2243
+ // When the user switches into a subagent session via <> / /sub:trans
2244
+ // and sends normal chat prompts, prepend the subagent's system prompt
2245
+ // so the main agent responds with that persona.
2246
+ const PERSONA_MARKER = "<!-- subagent-persona-injected -->";
2247
+
2248
+ pi.on("before_agent_start", async (event, ctx) => {
2249
+ // Skip if persona marker already present (avoid double-inject)
2250
+ if (event.systemPrompt.includes(PERSONA_MARKER)) return;
2251
+
2252
+ // Find latest PARENT_ENTRY_TYPE entry to determine if this is a sub-trans child session
2253
+ let latestEntry: any = null;
2254
+ try {
2255
+ const entries = ctx.sessionManager?.getEntries?.() ?? [];
2256
+ for (const entry of entries) {
2257
+ if ((entry as any).type === "custom" && (entry as any).customType === PARENT_ENTRY_TYPE) {
2258
+ latestEntry = entry;
2259
+ }
2260
+ }
2261
+ } catch {
2262
+ return;
2263
+ }
2264
+
2265
+ if (!latestEntry?.data) return;
2266
+
2267
+ // Resolve agent name: data.agent (new entries) or fallback via runId (legacy entries)
2268
+ let agentName: string | undefined = latestEntry.data.agent;
2269
+ if (!agentName && latestEntry.data.runId != null) {
2270
+ agentName = store.commandRuns.get(latestEntry.data.runId)?.agent;
2271
+ }
2272
+ if (!agentName) return;
2273
+
2274
+ // Discover agents and find exact match
2275
+ const discovery = discoverAgents(ctx.cwd);
2276
+ const agentConfig = discovery.agents.find((a) => a.name.toLowerCase() === agentName?.toLowerCase());
2277
+ if (!agentConfig?.systemPrompt?.trim()) return;
2278
+
2279
+ // Prepend persona block with marker
2280
+ const personaBlock = `${PERSONA_MARKER}\n${agentConfig.systemPrompt}`;
2281
+ return {
2282
+ systemPrompt: `${personaBlock}\n\n${event.systemPrompt}`,
2283
+ };
2284
+ });
2285
+
2286
+ pi.on("session_start", async (_event, ctx) => {
2287
+ restoreRunsFromSession(store, ctx, pi);
2288
+ registerTerminalInputRedirect(ctx);
2289
+ });
2290
+
2291
+ pi.on("session_shutdown", () => {
2292
+ unsubTerminalInput?.();
2293
+ unsubTerminalInput = null;
2294
+ });
2295
+ }