pi-subagent-in-memory 0.1.6 → 0.2.2

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/README.md CHANGED
@@ -23,11 +23,25 @@ Spawns an in-process subagent session using pi's `createAgentSession` SDK. The s
23
23
  | `provider` | string | | LLM provider (e.g. `anthropic`, `google`, `openai`) |
24
24
  | `model` | string | | Model ID. Supports `provider/model` format (e.g. `openai/gpt-4o-mini`) |
25
25
  | `cwd` | string | | Working directory for the subagent |
26
- | `timeout` | number | | Timeout in seconds. Aborts the subagent if exceeded |
26
+ | `timeout` | number | | Timeout in seconds. Aborts the subagent if exceeded. Defaults to the configured default timeout (300s, see `/saim-timeout`) |
27
27
  | `columnWidthPercent` | number | | Card width as % of terminal (33–100). Controls card grid layout |
28
28
 
29
29
  If `provider` and `model` are omitted, the subagent inherits the main agent's model.
30
30
 
31
+ ### ⏱️ Timeouts That Don't Destroy Child Work
32
+
33
+ Every subagent has a timeout (default **300s**, configurable via `--saim-timeout` / `/saim-timeout`, `0` = unlimited). When a parent agent is aborted or times out while a child subagent is still running, the child is **not killed**. Instead it detaches:
34
+
35
+ - The parent's tool call returns immediately with a note that the child continues in the background
36
+ - The child keeps running, still bounded by its **own** timeout
37
+ - When it finishes, its output is written to `result.md` (or `error.md` on failure) as usual, so the work can be harvested later
38
+
39
+ On any failure (timeout, error), whatever text the subagent had already produced is salvaged to `partial-result.md` alongside `error.md` — nothing is silently discarded.
40
+
41
+ ### 🛑 Nesting Depth Limit
42
+
43
+ Subagents may spawn their own subagents, but only up to a configured max depth (default **2**: main agent → subagent → sub-subagent). Subagents at the max depth don't receive the `subagent_create` tool at all, so a runaway fork cascade is structurally impossible. Configure via `--saim-max-depth` / `/saim-max-depth` (1–10).
44
+
31
45
  ### 📊 Live TUI Card Widgets
32
46
 
33
47
  Each running subagent is displayed as a colored card widget above the editor:
@@ -40,12 +54,20 @@ Each running subagent is displayed as a colored card widget above the editor:
40
54
 
41
55
  ### 🔍 Subagent Detail Overlay (`Ctrl+N`)
42
56
 
43
- Press **Ctrl+1** through **Ctrl+9** to open a detail popup for the corresponding subagent:
57
+ Press **Ctrl+1** through **Ctrl+9** to open a detail popup for the **N-th visible** subagent card (1 = leftmost/topmost in the current window):
44
58
 
45
59
  - **Prompt** — Full prompt text with word wrapping (up to 5 lines)
46
60
  - **Messages** — Live-updating stream of the subagent's activity (text output, tool calls, status changes), always showing the latest 5 lines
47
61
  - Press the same **Ctrl+N** shortcut or **Escape** to close the overlay
48
62
 
63
+ ### 📑 Paging Through Cards (`Ctrl+Alt+←/→`)
64
+
65
+ When more subagents have been spawned than fit in the visible window (see `/saim-set-max-tui-overlays` below):
66
+
67
+ - **Ctrl+Alt+←** scrolls back to older subagent cards
68
+ - **Ctrl+Alt+→** scrolls forward to newer subagent cards
69
+ - A `subagents X–Y of N (Ctrl+Alt+←/→ to page)` hint is displayed above the cards whenever paging is active
70
+
49
71
  ### 📝 JSONL Session Logging
50
72
 
51
73
  Every subagent session is logged to disk for debugging and auditing:
@@ -53,11 +75,12 @@ Every subagent session is logged to disk for debugging and auditing:
53
75
  ```
54
76
  .pi/subagent-in-memory/<mainSessionId>/
55
77
  ├── subagent_1/
56
- │ ├── events.jsonl # Full event stream (text, tool calls, results)
57
- │ └── result.md # Final subagent output (or error.md on failure)
78
+ │ ├── events.jsonl # Full event stream (text, tool calls, results)
79
+ │ └── result.md # Final subagent output
58
80
  ├── subagent_2/
59
81
  │ ├── events.jsonl
60
- └── result.md
82
+ ├── error.md # On failure: what went wrong
83
+ │ └── partial-result.md # On failure: any text produced before the failure
61
84
  └── ...
62
85
  ```
63
86
 
@@ -69,11 +92,25 @@ The JSONL log includes:
69
92
 
70
93
  ### 🔄 Nested Subagent Support
71
94
 
72
- Subagents can spawn their own subagents. All nested cards render in the main agent's widget — they share the same module-level state regardless of nesting depth. This is achieved by passing the `subagent_create` tool directly as an `AgentTool` to child sessions.
95
+ Subagents can spawn their own subagents, up to the configured max depth (see above). All nested cards render in the main agent's widget — they share the same module-level state regardless of nesting depth. This is achieved by passing the `subagent_create` tool directly as an `AgentTool` to child sessions.
96
+
97
+ ### 🎛️ Slash Commands
98
+
99
+ | Command | Description |
100
+ |---------|-------------|
101
+ | `/saim-max-depth [n]` | Show or set the max subagent nesting depth (1–10, default 2). Applies to newly created subagents. |
102
+ | `/saim-timeout [seconds]` | Show or set the default subagent timeout in seconds (default 300, `0` = unlimited). A per-call `timeout` parameter overrides it. |
103
+ | `/saim-toggle-overlay [on\|off\|toggle]` | Enable, disable, or toggle the subagent TUI overlay. When disabled, **no card widget is mounted even while subagents are actively running** — they continue executing silently in the background. |
104
+ | `/saim-set-max-tui-overlays <N>` | Set the maximum number of cards displayed at once (1–9, default 3). Older cards remain accessible via **Ctrl+Alt+←/→**. |
105
+ | `/saim-clear-tui-overlay` | Clear all subagent cards from the TUI and close any open detail overlay. |
73
106
 
74
- ### 🧹 `/in-memory-clear-widgets` Slash Command
107
+ ### 🚩 CLI Flags
75
108
 
76
- Type `/in-memory-clear-widgets` in the pi prompt to clear all subagent card widgets from the TUI. Useful after a batch of subagent runs when you want a clean view.
109
+ | Flag | Description |
110
+ |------|-------------|
111
+ | `--saim-max-depth <n>` | Max subagent nesting depth (1–10, default 2). Same as `/saim-max-depth`. |
112
+ | `--saim-timeout <seconds>` | Default subagent timeout in seconds (default 300, `0` = unlimited). Same as `/saim-timeout`. |
113
+ | `--saim-no-tui` | Start with the subagent overlay disabled (equivalent to running `/saim-toggle-overlay off` immediately on startup). Subagents still run normally — only the TUI cards are hidden. |
77
114
 
78
115
  ## Install
79
116
 
@@ -92,7 +129,7 @@ pi remove npm:pi-subagent-in-memory
92
129
  After installing, start pi and check:
93
130
 
94
131
  1. The `subagent_create` tool should appear in the tool list
95
- 2. The `/in-memory-clear-widgets` command should be available (type `/` to see commands)
132
+ 2. The `/saim-max-depth`, `/saim-timeout`, `/saim-toggle-overlay`, `/saim-set-max-tui-overlays`, and `/saim-clear-tui-overlay` commands should be available (type `/` to see commands)
96
133
  3. Ask the agent to "run a subagent to list files" — you should see a card widget appear
97
134
 
98
135
  ## Usage Examples
@@ -118,7 +155,7 @@ Once installed, the LLM will discover the `subagent_create` tool from its schema
118
155
 
119
156
  ## How It Works
120
157
 
121
- 1. **Tool registration** — On load, registers `subagent_create` as a tool and `/in-memory-clear-widgets` as a command. No system prompt modifications.
158
+ 1. **Tool registration** — On load, registers `subagent_create` as a tool plus the `/saim-*` commands and `--saim-no-tui` flag. No system prompt modifications.
122
159
  2. **Session creation** — When the LLM calls `subagent_create`, a new `createAgentSession` is created in-process with its own model, auth, and coding tools (read, write, edit, bash, grep, find, ls).
123
160
  3. **Event streaming** — All subagent events (text deltas, tool calls, completions) are forwarded as `tool_execution_update` events to the parent agent and logged to JSONL.
124
161
  4. **Widget rendering** — A TUI widget renders card(s) above the editor, updated on every event.
@@ -13,9 +13,22 @@
13
13
  * - Live TUI card widgets showing subagent status and output
14
14
  * - JSONL event logging to ~/.pi/subagent-in-memory/<sessionId>/
15
15
  * - Nested subagent support (subagents can spawn subagents)
16
- * - /in-memory-clear-widgets slash command to remove widget cards
16
+ * - Slash commands to control TUI overlay:
17
+ * /saim-toggle-overlay [on|off] — enable/disable rendering
18
+ * /saim-set-max-tui-overlays <N> — limit visible cards (1-9)
19
+ * /saim-clear-tui-overlay — clear all cards & close any overlay
20
+ * - Runtime limits (each also available as a CLI flag):
21
+ * /saim-max-depth [n] — max subagent nesting depth (default 2)
22
+ * /saim-timeout [seconds] — default subagent timeout (default 300s,
23
+ * 0 = unlimited)
24
+ * - Parent aborts/timeouts do NOT kill running children: the child detaches,
25
+ * keeps running under its own timeout, and still writes result.md /
26
+ * error.md (plus partial-result.md salvaging any text produced so far).
17
27
  * - Multi-provider support (Anthropic, OpenAI, Google, etc.)
18
- * - Ctrl+Alt+<N> to inspect subagent prompt & live messages
28
+ * - Ctrl+<N> to inspect subagent prompt & live messages
29
+ * - Ctrl+Alt+Left / Ctrl+Alt+Right to page through cards when there are
30
+ * more subagents than the visible window allows
31
+ * - --saim-no-tui CLI flag to start with the overlay disabled
19
32
  *
20
33
  * Results are written to ./.pi/subagent-in-memory/<mainSessionId>/subagent_<N>/result.md
21
34
  * (or error.md on failure) so the calling agent gets a short pointer instead of
@@ -24,19 +37,12 @@
24
37
 
25
38
  import { Type, type Static } from "@sinclair/typebox";
26
39
  import {
27
- createAgentSession,
40
+ createAgentSessionFromServices,
41
+ createAgentSessionServices,
28
42
  SessionManager,
29
- AuthStorage,
30
- DefaultResourceLoader,
31
- getAgentDir,
32
- createCodingTools,
33
- createGrepTool,
34
- createFindTool,
35
- createLsTool,
36
43
  } from "@mariozechner/pi-coding-agent";
37
44
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
38
- import type { AgentToolResult, AgentToolUpdateCallback } from "@mariozechner/pi-coding-agent";
39
- import { resolveModel } from "./model.ts";
45
+ import type { AgentToolResult, AgentToolUpdateCallback, ToolDefinition } from "@mariozechner/pi-coding-agent";
40
46
  import { renderCard, type CardTheme } from "./tui-draw.ts";
41
47
 
42
48
  import { visibleWidth, truncateToWidth, wrapTextWithAnsi, matchesKey, Key } from "@mariozechner/pi-tui";
@@ -142,21 +148,99 @@ let widgetRenderVersion = 0;
142
148
 
143
149
  // Track open detail overlay so we can trigger re-renders
144
150
  let activeDetailTui: any = null;
151
+ let activeDetailDone: ((result: void) => void) | null = null;
152
+
153
+ // ── Runtime limits (commands & flags) ───────────────────────────
154
+ const DEFAULT_MAX_DEPTH = 2;
155
+ const MAX_DEPTH_HARD_LIMIT = 10;
156
+ const DEFAULT_TIMEOUT_SECS = 300;
157
+ // Max nesting depth: 1 = only the main agent may spawn subagents,
158
+ // 2 = subagents may spawn one further level, etc. Prevents runaway forks.
159
+ let maxSubagentDepth = DEFAULT_MAX_DEPTH;
160
+ // Default per-subagent timeout in seconds, used when a subagent_create call
161
+ // doesn't pass `timeout` explicitly. 0 = unlimited.
162
+ let defaultTimeoutSecs = DEFAULT_TIMEOUT_SECS;
163
+
164
+ /** Thrown into the tool-call promise when the PARENT aborts, so the child can
165
+ * detach and keep running instead of being killed mid-flight. */
166
+ class ParentAbortedError extends Error {
167
+ constructor() {
168
+ super("Subagent was aborted by parent");
169
+ }
170
+ }
145
171
 
146
- function renderSubagentCards(theme: any, width: number): string[] {
172
+ function parseIntSetting(raw: unknown): number | undefined {
173
+ if (typeof raw !== "string") return undefined;
174
+ const trimmed = raw.trim();
175
+ if (trimmed === "") return undefined;
176
+ const n = Number(trimmed);
177
+ return Number.isInteger(n) ? n : Number.NaN;
178
+ }
179
+
180
+ // ── TUI overlay controls (commands & flag) ──────────────────────
181
+ const DEFAULT_MAX_VISIBLE_OVERLAYS = 3;
182
+ const MAX_OVERLAYS_HARD_LIMIT = 9;
183
+ let overlayEnabled = true;
184
+ let maxVisibleOverlays = DEFAULT_MAX_VISIBLE_OVERLAYS;
185
+ // Number of cards to skip from the END of the list. 0 means "show the latest
186
+ // `maxVisibleOverlays`". Increasing windowOffset pages back into older cards.
187
+ let windowOffset = 0;
188
+
189
+ function getVisibleSubagents(): SubagentCard[] {
147
190
  if (subagents.length === 0) return [];
191
+ const total = subagents.length;
192
+ const window = Math.min(maxVisibleOverlays, total);
193
+ // Clamp offset to valid range; user paging may have left it stale.
194
+ const maxOffset = Math.max(0, total - window);
195
+ if (windowOffset > maxOffset) windowOffset = maxOffset;
196
+ if (windowOffset < 0) windowOffset = 0;
197
+ const end = total - windowOffset;
198
+ const start = Math.max(0, end - window);
199
+ return subagents.slice(start, end);
200
+ }
201
+
202
+ function unmountWidget() {
203
+ if (!currentCtx) return;
204
+ if (widgetMounted) {
205
+ currentCtx.ui.setWidget("in-memory-subagent-cards", undefined);
206
+ }
207
+ widgetMounted = false;
208
+ widgetTui = null;
209
+ }
210
+
211
+ function closeActiveDetail() {
212
+ if (activeDetailDone) {
213
+ try { activeDetailDone(); } catch {}
214
+ }
215
+ activeDetailTui = null;
216
+ activeDetailDone = null;
217
+ }
218
+
219
+ function renderSubagentCards(theme: any, width: number): string[] {
220
+ if (!overlayEnabled) return [];
221
+ const visible = getVisibleSubagents();
222
+ if (visible.length === 0) return [];
148
223
 
149
224
  // Derive cols from columnWidthPercent (all cards share the same value).
150
- const pct = subagents[subagents.length - 1].columnWidthPercent;
225
+ const pct = visible[visible.length - 1].columnWidthPercent;
151
226
  const cols = Math.min(3, Math.max(1, Math.round(100 / pct)));
152
227
  const gap = 1;
153
228
  const colWidth = Math.floor((width - gap * (cols - 1)) / cols);
154
229
  const maxContentLines = 4;
155
230
  const lines: string[] = [""];
156
231
 
157
- for (let i = 0; i < subagents.length; i += cols) {
158
- const rowCards = subagents.slice(i, i + cols).map((sa, idx) => {
159
- const cardTheme = CARD_THEMES[(i + idx) % CARD_THEMES.length];
232
+ // Page indicator if there are more cards than fit on screen
233
+ const total = subagents.length;
234
+ if (total > visible.length) {
235
+ const firstVisible = total - windowOffset - visible.length + 1;
236
+ const lastVisible = total - windowOffset;
237
+ const hint = `subagents ${firstVisible}–${lastVisible} of ${total} (Ctrl+Alt+←/→ to page)`;
238
+ lines.push(theme.fg("dim", truncateToWidth(hint, width)));
239
+ }
240
+
241
+ for (let i = 0; i < visible.length; i += cols) {
242
+ const rowCards = visible.slice(i, i + cols).map((sa, idx) => {
243
+ const cardTheme = CARD_THEMES[(sa.num - 1) % CARD_THEMES.length];
160
244
 
161
245
  const titleText = `${sa.title} [${sa.modelLabel}]`;
162
246
  const innerW = colWidth - 4;
@@ -166,8 +250,8 @@ function renderSubagentCards(theme: any, width: number): string[] {
166
250
  const trimmedLines = contentLines.map((l) =>
167
251
  visibleWidth(l) > innerW ? truncateToWidth(l, innerW - 1) + "…" : l
168
252
  );
169
- const visible = trimmedLines.slice(0, maxContentLines);
170
- const content = visible.join("\n") + (contentLines.length > maxContentLines ? "\n…" : "");
253
+ const visibleContentLines = trimmedLines.slice(0, maxContentLines);
254
+ const content = visibleContentLines.join("\n") + (contentLines.length > maxContentLines ? "\n…" : "");
171
255
 
172
256
  let statusRaw: string;
173
257
  if (sa.status === "created") {
@@ -286,12 +370,8 @@ function requestSubagentRender() {
286
370
  syncAnimationTimer();
287
371
 
288
372
  if (!currentCtx) return;
289
- if (subagents.length === 0) {
290
- if (widgetMounted) {
291
- currentCtx.ui.setWidget("in-memory-subagent-cards", undefined);
292
- widgetMounted = false;
293
- widgetTui = null;
294
- }
373
+ if (!overlayEnabled || subagents.length === 0) {
374
+ unmountWidget();
295
375
  try { activeDetailTui?.requestRender(); } catch {}
296
376
  return;
297
377
  }
@@ -431,8 +511,9 @@ const SubagentParams = Type.Object({
431
511
  timeout: Type.Optional(
432
512
  Type.Number({
433
513
  description:
434
- "Timeout in seconds for the subagent execution. If exceeded, the subagent is aborted. " +
435
- "Defaults to unlimited (no timeout).",
514
+ "Timeout in seconds for the subagent execution. If exceeded, the subagent is aborted " +
515
+ "(its own in-flight nested subagents keep running and still write their results). " +
516
+ "Defaults to the configured default timeout (300s unless changed via --saim-timeout or /saim-timeout).",
436
517
  minimum: 1,
437
518
  })
438
519
  ),
@@ -458,7 +539,18 @@ async function executeSubagent(
458
539
  fallbackProvider?: string,
459
540
  fallbackModel?: string,
460
541
  fallbackCwd?: string,
542
+ depth: number = 1,
461
543
  ): Promise<AgentToolResult<any>> {
544
+ if (depth > maxSubagentDepth) {
545
+ throw new Error(
546
+ `Subagent nesting depth limit reached (max depth ${maxSubagentDepth}, see /saim-max-depth). ` +
547
+ "Perform the task directly instead of delegating to another subagent.",
548
+ );
549
+ }
550
+ if (signal?.aborted) {
551
+ throw new Error("Subagent was aborted before it started");
552
+ }
553
+
462
554
  subagentCount++;
463
555
  const subagentNum = subagents.length + 1; // display number based on currently visible cards
464
556
  const outDir = join(".pi", "subagent-in-memory", mainSessionId, `subagent_${subagentCount}`);
@@ -477,36 +569,49 @@ async function executeSubagent(
477
569
  throw new Error("Could not determine model. Provide provider and model parameters.");
478
570
  }
479
571
 
480
- const { model: resolvedModel, apiKey } = await resolveModel(providerName, modelId);
481
-
482
572
  const cwd = params.cwd ?? fallbackCwd ?? process.cwd();
483
573
 
484
- const authStorage = AuthStorage.create();
485
- authStorage.setRuntimeApiKey(providerName, apiKey);
486
-
487
- // Create tools for the subagent, including nested subagent support
488
- const tools = [
489
- ...createCodingTools(cwd),
490
- createGrepTool(cwd),
491
- createFindTool(cwd),
492
- createLsTool(cwd),
493
- createSubagentAgentTool(providerName, modelId, cwd),
494
- ];
495
-
496
- const resourceLoader = new DefaultResourceLoader({
497
- cwd,
498
- agentDir: getAgentDir(),
499
- });
500
- await resourceLoader.reload();
574
+ // Create pi's normal cwd-bound services first. This loads packages from
575
+ // ~/.pi/agent/settings.json and <cwd>/.pi/settings.json, then applies any
576
+ // extension-provided registerProvider() calls to the model registry. Resolving
577
+ // the requested model after this step is what makes package-provided models
578
+ // such as openai-codex/gpt-5.5 visible to subagents.
579
+ const services = await createAgentSessionServices({ cwd });
580
+ const resolvedModel = services.modelRegistry.find(providerName, modelId);
581
+ if (!resolvedModel) {
582
+ const providerModels = services.modelRegistry
583
+ .getAll()
584
+ .filter((model) => model.provider === providerName)
585
+ .map((model) => model.id)
586
+ .sort();
587
+ const diagnostics = services.diagnostics
588
+ .filter((diagnostic) => diagnostic.type === "error")
589
+ .map((diagnostic) => diagnostic.message);
590
+ const details = [
591
+ providerModels.length > 0
592
+ ? `Known models for ${providerName}: ${providerModels.join(", ")}`
593
+ : `Provider ${providerName} has no registered models.`,
594
+ diagnostics.length > 0 ? `Service diagnostics: ${diagnostics.join("; ")}` : undefined,
595
+ ].filter(Boolean).join(" ");
596
+ throw new Error(`Could not find model ${providerName}/${modelId}. ${details}`.trim());
597
+ }
501
598
 
502
- const { session } = await createAgentSession({
599
+ // Subagents at the max depth don't get the subagent_create tool at all, so
600
+ // the LLM can't even attempt a deeper fork.
601
+ const canNest = depth < maxSubagentDepth;
602
+ const { session } = await createAgentSessionFromServices({
603
+ services,
604
+ sessionManager: SessionManager.inMemory(),
503
605
  model: resolvedModel,
504
- authStorage,
505
- tools,
506
606
  thinkingLevel: "off",
507
- cwd,
508
- sessionManager: SessionManager.inMemory(),
509
- resourceLoader,
607
+ // Keep subagent context/tool surface intentionally small: built-in coding
608
+ // tools plus nested subagent support. Package extensions are still loaded
609
+ // above so provider registrations are available, but their tools are not
610
+ // activated unless explicitly listed here.
611
+ tools: canNest
612
+ ? ["read", "bash", "edit", "write", "grep", "find", "ls", "subagent_create"]
613
+ : ["read", "bash", "edit", "write", "grep", "find", "ls"],
614
+ customTools: canNest ? [createSubagentAgentTool(providerName, modelId, cwd, depth + 1)] : [],
510
615
  });
511
616
 
512
617
  // Set up JSONL event log
@@ -522,6 +627,7 @@ async function executeSubagent(
522
627
  model: modelId,
523
628
  task: params.task,
524
629
  title: params.title,
630
+ depth,
525
631
  });
526
632
  let lastEventId = session.sessionId;
527
633
 
@@ -545,26 +651,21 @@ async function executeSubagent(
545
651
  details: { sessionId: session.sessionId, status: "created" },
546
652
  });
547
653
 
548
- // Timeout handling
654
+ // Timeout handling — explicit params.timeout wins, otherwise the configured
655
+ // default applies. 0 means unlimited.
656
+ const timeoutSecs = params.timeout ?? defaultTimeoutSecs;
549
657
  let timeoutTimer: ReturnType<typeof setTimeout> | undefined;
550
658
  const timeoutController = new AbortController();
551
- if (params.timeout) {
659
+ if (timeoutSecs > 0) {
552
660
  timeoutTimer = setTimeout(() => {
553
661
  timeoutController.abort();
554
- }, params.timeout * 1000);
662
+ }, timeoutSecs * 1000);
555
663
  }
556
664
 
557
- const combinedAbort = () => {
558
- session.abort();
559
- card.status = "error";
560
- card.endedAt = Date.now();
561
- appendMessage(card, "[aborted]");
562
- requestSubagentRender();
563
- };
665
+ // Hoisted out of the run promise so error paths can salvage partial output.
666
+ let finalText = "";
564
667
 
565
- try {
566
- const result = await new Promise<string>((resolve, reject) => {
567
- let finalText = "";
668
+ const runPromise = new Promise<string>((resolve, reject) => {
568
669
  let textDeltaBuffer = "";
569
670
  let toolcallDeltaBuffer = "";
570
671
  let lastPartialUpdateAt = 0;
@@ -714,15 +815,17 @@ async function executeSubagent(
714
815
  }
715
816
  });
716
817
 
717
- if (signal) {
718
- signal.addEventListener("abort", () => {
719
- combinedAbort();
720
- reject(new Error("Subagent was aborted"));
721
- });
722
- }
723
818
  timeoutController.signal.addEventListener("abort", () => {
724
- combinedAbort();
725
- reject(new Error(`Subagent timed out after ${params.timeout}s`));
819
+ // Abort only this subagent's own session. Its in-flight nested
820
+ // subagents receive the abort as a PARENT abort and detach instead of
821
+ // dying (see ParentAbortedError handling below), so their work is
822
+ // still written to disk.
823
+ session.abort();
824
+ card.status = "error";
825
+ card.endedAt = Date.now();
826
+ appendMessage(card, `[timed out after ${timeoutSecs}s]`);
827
+ requestSubagentRender();
828
+ reject(new Error(`Subagent timed out after ${timeoutSecs}s`));
726
829
  });
727
830
 
728
831
  session.prompt(params.task).catch((err) => {
@@ -732,30 +835,85 @@ async function executeSubagent(
732
835
  requestSubagentRender();
733
836
  reject(err);
734
837
  });
735
- });
838
+ });
736
839
 
840
+ const finalizeSuccess = async (result: string): Promise<string> => {
737
841
  if (timeoutTimer) clearTimeout(timeoutTimer);
738
842
  session.dispose();
739
843
  await flushJsonl(jsonlPath);
740
-
741
844
  const resultPath = join(outDir, "result.md");
742
845
  writeFileSync(resultPath, result, "utf-8");
846
+ return resultPath;
847
+ };
743
848
 
744
- return {
745
- content: [{ type: "text", text: `Execution succeeded. Result is in \`${resultPath}\`` }],
746
- details: { sessionId: session.sessionId, status: "completed", outputDir: outDir },
747
- };
748
- } catch (err: any) {
849
+ const finalizeError = async (err: any): Promise<{ errorPath: string; partialPath?: string }> => {
749
850
  if (timeoutTimer) clearTimeout(timeoutTimer);
750
851
  session.dispose();
751
852
  await flushJsonl(jsonlPath);
752
-
753
853
  const errorMsg = err?.message ?? String(err);
754
854
  const errorPath = join(outDir, "error.md");
755
- writeFileSync(errorPath, `# Subagent Error\n\n${errorMsg}\n`, "utf-8");
855
+ let body = `# Subagent Error\n\n${errorMsg}\n`;
856
+ let partialPath: string | undefined;
857
+ if (finalText.trim()) {
858
+ partialPath = join(outDir, "partial-result.md");
859
+ writeFileSync(partialPath, finalText, "utf-8");
860
+ body += `\nPartial output produced before the failure was saved to \`partial-result.md\`.\n`;
861
+ }
862
+ writeFileSync(errorPath, body, "utf-8");
863
+ return { errorPath, partialPath };
864
+ };
865
+
866
+ // A parent abort races against the run instead of killing the session, so
867
+ // a timed-out/aborted parent can no longer destroy useful child work.
868
+ let parentAbortPromise: Promise<never> | undefined;
869
+ if (signal) {
870
+ parentAbortPromise = new Promise<never>((_, reject) => {
871
+ signal.addEventListener("abort", () => reject(new ParentAbortedError()), { once: true });
872
+ });
873
+ }
756
874
 
875
+ try {
876
+ const result = await (parentAbortPromise ? Promise.race([runPromise, parentAbortPromise]) : runPromise);
877
+ const resultPath = await finalizeSuccess(result);
757
878
  return {
758
- content: [{ type: "text", text: `Execution failed. Detail is in \`${errorPath}\`` }],
879
+ content: [{ type: "text", text: `Execution succeeded. Result is in \`${resultPath}\`` }],
880
+ details: { sessionId: session.sessionId, status: "completed", outputDir: outDir },
881
+ };
882
+ } catch (err: any) {
883
+ if (err instanceof ParentAbortedError) {
884
+ // The parent aborted (typically because it hit its own timeout) while
885
+ // this subagent was still working. Don't kill it — detach: let it run
886
+ // to completion, still bounded by its own timeout, and write result.md
887
+ // / error.md as usual so the work can be harvested later.
888
+ appendMessage(card, "[parent aborted — continuing detached]");
889
+ requestSubagentRender();
890
+ runPromise
891
+ .then((result) => finalizeSuccess(result))
892
+ .catch((e) => finalizeError(e))
893
+ .catch(() => {});
894
+ return {
895
+ content: [
896
+ {
897
+ type: "text",
898
+ text:
899
+ "Subagent was aborted by its parent but continues running detached (its own timeout still applies). " +
900
+ `Its result will be written to \`${outDir}\` when it finishes.`,
901
+ },
902
+ ],
903
+ details: { sessionId: session.sessionId, status: "detached", outputDir: outDir },
904
+ };
905
+ }
906
+
907
+ const { errorPath, partialPath } = await finalizeError(err);
908
+ return {
909
+ content: [
910
+ {
911
+ type: "text",
912
+ text:
913
+ `Execution failed. Detail is in \`${errorPath}\`` +
914
+ (partialPath ? ` (partial output in \`${partialPath}\`)` : ""),
915
+ },
916
+ ],
759
917
  details: { sessionId: session.sessionId, status: "error", outputDir: outDir },
760
918
  };
761
919
  }
@@ -766,19 +924,22 @@ function createSubagentAgentTool(
766
924
  parentProvider: string,
767
925
  parentModel: string,
768
926
  parentCwd: string,
769
- ) {
927
+ childDepth: number,
928
+ ): ToolDefinition<typeof SubagentParams> {
770
929
  return {
771
930
  name: "subagent_create",
772
931
  label: "Subagent",
773
932
  description:
774
933
  "Create a subagent to perform a task. The subagent runs in-process with its own session. " +
775
- "Progress is streamed back as execution updates. Returns the final result when the subagent finishes.",
934
+ "Progress is streamed back as execution updates. Returns the final result when the subagent finishes. " +
935
+ "Nesting is limited to a configured max depth — if the limit is reached, do the work directly instead.",
776
936
  parameters: SubagentParams,
777
937
  async execute(
778
938
  toolCallId: string,
779
939
  params: SubagentParamsType,
780
940
  signal?: AbortSignal,
781
941
  onUpdate?: AgentToolUpdateCallback,
942
+ _ctx?: any,
782
943
  ) {
783
944
  return executeSubagent(
784
945
  toolCallId,
@@ -788,6 +949,7 @@ function createSubagentAgentTool(
788
949
  parentProvider,
789
950
  parentModel,
790
951
  parentCwd,
952
+ childDepth,
791
953
  );
792
954
  },
793
955
  };
@@ -795,46 +957,213 @@ function createSubagentAgentTool(
795
957
 
796
958
  // ── Extension entry point ───────────────────────────────────────
797
959
  export default function (pi: ExtensionAPI) {
960
+ pi.registerFlag("saim-no-tui", {
961
+ description: "Start with the subagent TUI overlay disabled (equivalent to /saim-toggle-overlay off).",
962
+ type: "boolean",
963
+ default: false,
964
+ });
965
+
966
+ pi.registerFlag("saim-max-depth", {
967
+ description: `Max subagent nesting depth, 1-${MAX_DEPTH_HARD_LIMIT} (default ${DEFAULT_MAX_DEPTH}). Prevents runaway subagent forks.`,
968
+ type: "string",
969
+ });
970
+
971
+ pi.registerFlag("saim-timeout", {
972
+ description: `Default subagent timeout in seconds, 0 = unlimited (default ${DEFAULT_TIMEOUT_SECS}). Per-call \`timeout\` parameter overrides it.`,
973
+ type: "string",
974
+ });
975
+
798
976
  pi.on("session_start", async (_event, ctx) => {
799
977
  currentCtx = ctx;
800
978
  mainSessionId = ctx.sessionManager.getSessionId?.() ?? `session-${Date.now()}`;
801
979
  subagentCount = 0;
802
980
  subagents.length = 0;
803
981
  activeDetailTui = null;
982
+ activeDetailDone = null;
804
983
  widgetMounted = false;
805
984
  widgetTui = null;
985
+ windowOffset = 0;
806
986
  if (flashTimer) { clearInterval(flashTimer); flashTimer = null; }
807
987
  ctx.ui.setWidget("in-memory-subagent-cards", undefined);
988
+
989
+ // Apply --saim-no-tui flag if present
990
+ if (pi.getFlag("saim-no-tui") === true) {
991
+ overlayEnabled = false;
992
+ }
993
+
994
+ // Apply --saim-max-depth / --saim-timeout flags if present
995
+ const depthFlag = parseIntSetting(pi.getFlag("saim-max-depth"));
996
+ if (depthFlag !== undefined) {
997
+ if (Number.isInteger(depthFlag) && depthFlag >= 1 && depthFlag <= MAX_DEPTH_HARD_LIMIT) {
998
+ maxSubagentDepth = depthFlag;
999
+ } else {
1000
+ ctx.ui.notify(
1001
+ `Invalid --saim-max-depth (expected 1-${MAX_DEPTH_HARD_LIMIT}); keeping ${maxSubagentDepth}`,
1002
+ "warning",
1003
+ );
1004
+ }
1005
+ }
1006
+ const timeoutFlag = parseIntSetting(pi.getFlag("saim-timeout"));
1007
+ if (timeoutFlag !== undefined) {
1008
+ if (Number.isInteger(timeoutFlag) && timeoutFlag >= 0) {
1009
+ defaultTimeoutSecs = timeoutFlag;
1010
+ } else {
1011
+ ctx.ui.notify(
1012
+ `Invalid --saim-timeout (expected seconds >= 0); keeping ${defaultTimeoutSecs}`,
1013
+ "warning",
1014
+ );
1015
+ }
1016
+ }
1017
+
808
1018
  requestSubagentRender();
809
1019
  });
810
1020
 
811
- pi.registerCommand("in-memory-clear-widgets", {
812
- description: "Clear all in-memory subagent card widgets",
1021
+ pi.registerCommand("saim-max-depth", {
1022
+ description: `Show or set max subagent nesting depth (1-${MAX_DEPTH_HARD_LIMIT}, default ${DEFAULT_MAX_DEPTH}). Usage: /saim-max-depth [n]`,
1023
+ handler: async (args, ctx) => {
1024
+ const arg = (args ?? "").trim();
1025
+ if (arg === "") {
1026
+ ctx.ui.notify(`Max subagent nesting depth: ${maxSubagentDepth}`, "info");
1027
+ return;
1028
+ }
1029
+ const n = Number(arg);
1030
+ if (!Number.isInteger(n) || n < 1 || n > MAX_DEPTH_HARD_LIMIT) {
1031
+ ctx.ui.notify(`Provide an integer between 1 and ${MAX_DEPTH_HARD_LIMIT}.`, "warning");
1032
+ return;
1033
+ }
1034
+ maxSubagentDepth = n;
1035
+ ctx.ui.notify(`Max subagent nesting depth set to ${n} (applies to newly created subagents)`, "info");
1036
+ },
1037
+ });
1038
+
1039
+ pi.registerCommand("saim-timeout", {
1040
+ description: `Show or set the default subagent timeout in seconds (0 = unlimited, default ${DEFAULT_TIMEOUT_SECS}). Usage: /saim-timeout [seconds]`,
1041
+ handler: async (args, ctx) => {
1042
+ const arg = (args ?? "").trim();
1043
+ const fmt = (secs: number) => (secs === 0 ? "unlimited" : `${secs}s`);
1044
+ if (arg === "") {
1045
+ ctx.ui.notify(`Default subagent timeout: ${fmt(defaultTimeoutSecs)}`, "info");
1046
+ return;
1047
+ }
1048
+ const n = Number(arg);
1049
+ if (!Number.isInteger(n) || n < 0) {
1050
+ ctx.ui.notify("Provide a non-negative integer number of seconds (0 = unlimited).", "warning");
1051
+ return;
1052
+ }
1053
+ defaultTimeoutSecs = n;
1054
+ ctx.ui.notify(`Default subagent timeout set to ${fmt(n)} (applies to newly created subagents)`, "info");
1055
+ },
1056
+ });
1057
+
1058
+ pi.registerCommand("saim-clear-tui-overlay", {
1059
+ description: "Clear subagent TUI cards and close any open detail overlay",
813
1060
  handler: async (_args, ctx) => {
814
1061
  subagents.length = 0;
815
- activeDetailTui = null;
816
- widgetMounted = false;
817
- widgetTui = null;
1062
+ windowOffset = 0;
1063
+ closeActiveDetail();
1064
+ unmountWidget();
818
1065
  if (flashTimer) { clearInterval(flashTimer); flashTimer = null; }
819
- ctx.ui.setWidget("in-memory-subagent-cards", undefined);
820
- ctx.ui.notify("In-memory subagent widgets cleared", "info");
1066
+ ctx.ui.notify("Subagent TUI cards cleared", "info");
1067
+ },
1068
+ });
1069
+
1070
+ pi.registerCommand("saim-toggle-overlay", {
1071
+ description: "Enable or disable subagent detail overlays. Usage: /saim-toggle-overlay [on|off|toggle]",
1072
+ handler: async (args, ctx) => {
1073
+ const arg = (args ?? "").trim().toLowerCase();
1074
+ let next: boolean;
1075
+ if (arg === "on" || arg === "true" || arg === "1") {
1076
+ next = true;
1077
+ } else if (arg === "off" || arg === "false" || arg === "0") {
1078
+ next = false;
1079
+ } else if (arg === "" || arg === "toggle") {
1080
+ next = !overlayEnabled;
1081
+ } else {
1082
+ ctx.ui.notify(`Unknown argument "${arg}". Use on, off, or toggle.`, "warning");
1083
+ return;
1084
+ }
1085
+ overlayEnabled = next;
1086
+ if (!overlayEnabled) {
1087
+ // Force-tear down even while subagents are running.
1088
+ closeActiveDetail();
1089
+ unmountWidget();
1090
+ }
1091
+ requestSubagentRender();
1092
+ ctx.ui.notify(`Subagent TUI overlay ${overlayEnabled ? "enabled" : "disabled"}`, "info");
1093
+ },
1094
+ });
1095
+
1096
+ pi.registerCommand("saim-set-max-tui-overlays", {
1097
+ description: `Set max visible subagent cards (1-${MAX_OVERLAYS_HARD_LIMIT}). Older cards remain accessible via Ctrl+Alt+←/→.`,
1098
+ handler: async (args, ctx) => {
1099
+ const n = parseInt((args ?? "").trim(), 10);
1100
+ if (!Number.isFinite(n) || n < 1 || n > MAX_OVERLAYS_HARD_LIMIT) {
1101
+ ctx.ui.notify(
1102
+ `Provide an integer between 1 and ${MAX_OVERLAYS_HARD_LIMIT}.`,
1103
+ "warning",
1104
+ );
1105
+ return;
1106
+ }
1107
+ maxVisibleOverlays = n;
1108
+ windowOffset = 0;
1109
+ requestSubagentRender();
1110
+ ctx.ui.notify(`Max visible subagent cards set to ${n}`, "info");
1111
+ },
1112
+ });
1113
+
1114
+ // Page through cards when there are more subagents than fit on screen.
1115
+ pi.registerShortcut(Key.ctrlAlt("left"), {
1116
+ description: "Page to older subagent cards",
1117
+ handler: async (ctx) => {
1118
+ if (!overlayEnabled) return;
1119
+ const total = subagents.length;
1120
+ const window = Math.min(maxVisibleOverlays, total);
1121
+ const maxOffset = Math.max(0, total - window);
1122
+ if (windowOffset >= maxOffset) {
1123
+ ctx.ui.notify("Already showing the oldest subagents", "info");
1124
+ return;
1125
+ }
1126
+ windowOffset = Math.min(maxOffset, windowOffset + window);
1127
+ requestSubagentRender();
821
1128
  },
822
1129
  });
823
1130
 
824
- // Register Ctrl+1 through Ctrl+9 to open subagent detail overlay
1131
+ pi.registerShortcut(Key.ctrlAlt("right"), {
1132
+ description: "Page to newer subagent cards",
1133
+ handler: async (ctx) => {
1134
+ if (!overlayEnabled) return;
1135
+ if (windowOffset === 0) {
1136
+ ctx.ui.notify("Already showing the latest subagents", "info");
1137
+ return;
1138
+ }
1139
+ const window = Math.min(maxVisibleOverlays, subagents.length);
1140
+ windowOffset = Math.max(0, windowOffset - window);
1141
+ requestSubagentRender();
1142
+ },
1143
+ });
1144
+
1145
+ // Register Ctrl+1 through Ctrl+9 to open subagent detail overlay.
1146
+ // The number maps to the position WITHIN the currently visible window
1147
+ // (1 = first visible card), not the absolute subagent number.
825
1148
  for (let n = 1; n <= 9; n++) {
826
1149
  pi.registerShortcut(Key.ctrl(`${n}` as any), {
827
- description: `Inspect subagent #${n}`,
1150
+ description: `Inspect visible subagent #${n}`,
828
1151
  handler: async (ctx) => {
829
- const card = subagents.find((sa) => sa.num === n);
1152
+ if (!overlayEnabled) {
1153
+ ctx.ui.notify("Subagent TUI overlay is disabled", "warning");
1154
+ return;
1155
+ }
1156
+ const visible = getVisibleSubagents();
1157
+ const card = visible[n - 1];
830
1158
  if (!card) {
831
- ctx.ui.notify(`No subagent #${n}`, "warning");
1159
+ ctx.ui.notify(`No visible subagent #${n}`, "warning");
832
1160
  return;
833
1161
  }
834
1162
 
835
1163
  await ctx.ui.custom<void>(
836
1164
  (tui: any, theme: any, _keybindings: any, done: (result: void) => void) => {
837
1165
  activeDetailTui = tui;
1166
+ activeDetailDone = done;
838
1167
  return new SubagentDetailOverlay(card, n, theme, done);
839
1168
  },
840
1169
  {
@@ -849,6 +1178,7 @@ export default function (pi: ExtensionAPI) {
849
1178
  );
850
1179
 
851
1180
  activeDetailTui = null;
1181
+ activeDetailDone = null;
852
1182
  syncAnimationTimer();
853
1183
  requestSubagentRender();
854
1184
  },
@@ -860,7 +1190,8 @@ export default function (pi: ExtensionAPI) {
860
1190
  label: "Subagent",
861
1191
  description:
862
1192
  "Create a subagent to perform a task. The subagent runs in-process with its own session. " +
863
- "Progress is streamed back as execution updates. Returns the final result when the subagent finishes.",
1193
+ "Progress is streamed back as execution updates. Returns the final result when the subagent finishes. " +
1194
+ "Nesting is limited to a configured max depth — if the limit is reached, do the work directly instead.",
864
1195
  parameters: SubagentParams,
865
1196
 
866
1197
  async execute(toolCallId, params, signal, onUpdate, ctx) {
@@ -877,6 +1208,7 @@ export default function (pi: ExtensionAPI) {
877
1208
  providerName,
878
1209
  modelId,
879
1210
  cwd,
1211
+ 1,
880
1212
  );
881
1213
  },
882
1214
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-subagent-in-memory",
3
- "version": "0.1.6",
3
+ "version": "0.2.2",
4
4
  "description": "In-process subagent tool for pi with live TUI card widgets, JSONL session logging, and zero system-prompt overhead.",
5
5
  "repository": {
6
6
  "type": "git",