pi-subagent-in-memory 0.2.1 → 0.2.3

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
@@ -22,12 +22,27 @@ Spawns an in-process subagent session using pi's `createAgentSession` SDK. The s
22
22
  | `title` | string | | Display title for the card widget |
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
+ | `thinkingLevel` | string | | Reasoning effort: `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, or `max`. Defaults to `off` |
25
26
  | `cwd` | string | | Working directory for the subagent |
26
- | `timeout` | number | | Timeout in seconds. Aborts the subagent if exceeded |
27
+ | `timeout` | number | | Timeout in seconds. Aborts the subagent if exceeded. Defaults to the configured default timeout (300s, see `/saim-timeout`) |
27
28
  | `columnWidthPercent` | number | | Card width as % of terminal (33–100). Controls card grid layout |
28
29
 
29
30
  If `provider` and `model` are omitted, the subagent inherits the main agent's model.
30
31
 
32
+ ### ⏱️ Timeouts That Don't Destroy Child Work
33
+
34
+ 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:
35
+
36
+ - The parent's tool call returns immediately with a note that the child continues in the background
37
+ - The child keeps running, still bounded by its **own** timeout
38
+ - When it finishes, its output is written to `result.md` (or `error.md` on failure) as usual, so the work can be harvested later
39
+
40
+ 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.
41
+
42
+ ### 🛑 Nesting Depth Limit
43
+
44
+ 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).
45
+
31
46
  ### 📊 Live TUI Card Widgets
32
47
 
33
48
  Each running subagent is displayed as a colored card widget above the editor:
@@ -61,11 +76,12 @@ Every subagent session is logged to disk for debugging and auditing:
61
76
  ```
62
77
  .pi/subagent-in-memory/<mainSessionId>/
63
78
  ├── subagent_1/
64
- │ ├── events.jsonl # Full event stream (text, tool calls, results)
65
- │ └── result.md # Final subagent output (or error.md on failure)
79
+ │ ├── events.jsonl # Full event stream (text, tool calls, results)
80
+ │ └── result.md # Final subagent output
66
81
  ├── subagent_2/
67
82
  │ ├── events.jsonl
68
- └── result.md
83
+ ├── error.md # On failure: what went wrong
84
+ │ └── partial-result.md # On failure: any text produced before the failure
69
85
  └── ...
70
86
  ```
71
87
 
@@ -77,19 +93,25 @@ The JSONL log includes:
77
93
 
78
94
  ### 🔄 Nested Subagent Support
79
95
 
80
- 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.
96
+ 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.
81
97
 
82
- ### 🎛️ TUI Overlay Slash Commands
98
+ ### 🎛️ Slash Commands
83
99
 
84
100
  | Command | Description |
85
101
  |---------|-------------|
102
+ | `/saim-max-depth [n]` | Show or set the max subagent nesting depth (1–10, default 2). Applies to newly created subagents. |
103
+ | `/saim-timeout [seconds]` | Show or set the default subagent timeout in seconds (default 300, `0` = unlimited). A per-call `timeout` parameter overrides it. |
86
104
  | `/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. |
87
105
  | `/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+←/→**. |
88
106
  | `/saim-clear-tui-overlay` | Clear all subagent cards from the TUI and close any open detail overlay. |
89
107
 
90
- ### 🚩 `--saim-no-tui` CLI Flag
108
+ ### 🚩 CLI Flags
91
109
 
92
- Start `pi` with `--saim-no-tui` to launch 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.
110
+ | Flag | Description |
111
+ |------|-------------|
112
+ | `--saim-max-depth <n>` | Max subagent nesting depth (1–10, default 2). Same as `/saim-max-depth`. |
113
+ | `--saim-timeout <seconds>` | Default subagent timeout in seconds (default 300, `0` = unlimited). Same as `/saim-timeout`. |
114
+ | `--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. |
93
115
 
94
116
  ## Install
95
117
 
@@ -108,7 +130,7 @@ pi remove npm:pi-subagent-in-memory
108
130
  After installing, start pi and check:
109
131
 
110
132
  1. The `subagent_create` tool should appear in the tool list
111
- 2. The `/saim-toggle-overlay`, `/saim-set-max-tui-overlays`, and `/saim-clear-tui-overlay` commands should be available (type `/` to see commands)
133
+ 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)
112
134
  3. Ask the agent to "run a subagent to list files" — you should see a card widget appear
113
135
 
114
136
  ## Usage Examples
@@ -122,8 +144,8 @@ Once installed, the LLM will discover the `subagent_create` tool from its schema
122
144
  # Parallel subagents
123
145
  "Run 2 subagents in parallel: one to summarize src/ and another to summarize tests/"
124
146
 
125
- # Different models
126
- "Use a subagent with openai/gpt-4o-mini to review the README"
147
+ # Different models and reasoning effort
148
+ "Use a subagent with openai/gpt-4o-mini and medium thinking to review the README"
127
149
 
128
150
  # With timeout
129
151
  "Spawn a subagent with a 60-second timeout to count lines of code"
@@ -17,6 +17,13 @@
17
17
  * /saim-toggle-overlay [on|off] — enable/disable rendering
18
18
  * /saim-set-max-tui-overlays <N> — limit visible cards (1-9)
19
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).
20
27
  * - Multi-provider support (Anthropic, OpenAI, Google, etc.)
21
28
  * - Ctrl+<N> to inspect subagent prompt & live messages
22
29
  * - Ctrl+Alt+Left / Ctrl+Alt+Right to page through cards when there are
@@ -143,6 +150,33 @@ let widgetRenderVersion = 0;
143
150
  let activeDetailTui: any = null;
144
151
  let activeDetailDone: ((result: void) => void) | null = null;
145
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
+ }
171
+
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
+
146
180
  // ── TUI overlay controls (commands & flag) ──────────────────────
147
181
  const DEFAULT_MAX_VISIBLE_OVERLAYS = 3;
148
182
  const MAX_OVERLAYS_HARD_LIMIT = 9;
@@ -471,14 +505,22 @@ const SubagentParams = Type.Object({
471
505
  model: Type.Optional(
472
506
  Type.String({ description: "Model ID (e.g. 'claude-sonnet-4-5'). Defaults to the main agent's model." })
473
507
  ),
508
+ thinkingLevel: Type.Optional(
509
+ Type.Unsafe<"off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max">({
510
+ type: "string",
511
+ enum: ["off", "minimal", "low", "medium", "high", "xhigh", "max"],
512
+ description: "Reasoning effort for the subagent. Defaults to 'off'.",
513
+ })
514
+ ),
474
515
  cwd: Type.Optional(
475
516
  Type.String({ description: "Working directory for the subagent. Defaults to the main agent's cwd." })
476
517
  ),
477
518
  timeout: Type.Optional(
478
519
  Type.Number({
479
520
  description:
480
- "Timeout in seconds for the subagent execution. If exceeded, the subagent is aborted. " +
481
- "Defaults to unlimited (no timeout).",
521
+ "Timeout in seconds for the subagent execution. If exceeded, the subagent is aborted " +
522
+ "(its own in-flight nested subagents keep running and still write their results). " +
523
+ "Defaults to the configured default timeout (300s unless changed via --saim-timeout or /saim-timeout).",
482
524
  minimum: 1,
483
525
  })
484
526
  ),
@@ -504,7 +546,18 @@ async function executeSubagent(
504
546
  fallbackProvider?: string,
505
547
  fallbackModel?: string,
506
548
  fallbackCwd?: string,
549
+ depth: number = 1,
507
550
  ): Promise<AgentToolResult<any>> {
551
+ if (depth > maxSubagentDepth) {
552
+ throw new Error(
553
+ `Subagent nesting depth limit reached (max depth ${maxSubagentDepth}, see /saim-max-depth). ` +
554
+ "Perform the task directly instead of delegating to another subagent.",
555
+ );
556
+ }
557
+ if (signal?.aborted) {
558
+ throw new Error("Subagent was aborted before it started");
559
+ }
560
+
508
561
  subagentCount++;
509
562
  const subagentNum = subagents.length + 1; // display number based on currently visible cards
510
563
  const outDir = join(".pi", "subagent-in-memory", mainSessionId, `subagent_${subagentCount}`);
@@ -527,15 +580,14 @@ async function executeSubagent(
527
580
 
528
581
  // Create pi's normal cwd-bound services first. This loads packages from
529
582
  // ~/.pi/agent/settings.json and <cwd>/.pi/settings.json, then applies any
530
- // extension-provided registerProvider() calls to the model registry. Resolving
583
+ // extension-provided registerProvider() calls to the model runtime. Resolving
531
584
  // the requested model after this step is what makes package-provided models
532
585
  // such as openai-codex/gpt-5.5 visible to subagents.
533
586
  const services = await createAgentSessionServices({ cwd });
534
- const resolvedModel = services.modelRegistry.find(providerName, modelId);
587
+ const resolvedModel = services.modelRuntime.getModel(providerName, modelId);
535
588
  if (!resolvedModel) {
536
- const providerModels = services.modelRegistry
537
- .getAll()
538
- .filter((model) => model.provider === providerName)
589
+ const providerModels = services.modelRuntime
590
+ .getModels(providerName)
539
591
  .map((model) => model.id)
540
592
  .sort();
541
593
  const diagnostics = services.diagnostics
@@ -550,17 +602,22 @@ async function executeSubagent(
550
602
  throw new Error(`Could not find model ${providerName}/${modelId}. ${details}`.trim());
551
603
  }
552
604
 
605
+ // Subagents at the max depth don't get the subagent_create tool at all, so
606
+ // the LLM can't even attempt a deeper fork.
607
+ const canNest = depth < maxSubagentDepth;
553
608
  const { session } = await createAgentSessionFromServices({
554
609
  services,
555
610
  sessionManager: SessionManager.inMemory(),
556
611
  model: resolvedModel,
557
- thinkingLevel: "off",
612
+ thinkingLevel: params.thinkingLevel ?? "off",
558
613
  // Keep subagent context/tool surface intentionally small: built-in coding
559
614
  // tools plus nested subagent support. Package extensions are still loaded
560
615
  // above so provider registrations are available, but their tools are not
561
616
  // activated unless explicitly listed here.
562
- tools: ["read", "bash", "edit", "write", "grep", "find", "ls", "subagent_create"],
563
- customTools: [createSubagentAgentTool(providerName, modelId, cwd)],
617
+ tools: canNest
618
+ ? ["read", "bash", "edit", "write", "grep", "find", "ls", "subagent_create"]
619
+ : ["read", "bash", "edit", "write", "grep", "find", "ls"],
620
+ customTools: canNest ? [createSubagentAgentTool(providerName, modelId, cwd, depth + 1)] : [],
564
621
  });
565
622
 
566
623
  // Set up JSONL event log
@@ -574,8 +631,10 @@ async function executeSubagent(
574
631
  cwd,
575
632
  provider: providerName,
576
633
  model: modelId,
634
+ thinkingLevel: params.thinkingLevel ?? "off",
577
635
  task: params.task,
578
636
  title: params.title,
637
+ depth,
579
638
  });
580
639
  let lastEventId = session.sessionId;
581
640
 
@@ -599,26 +658,21 @@ async function executeSubagent(
599
658
  details: { sessionId: session.sessionId, status: "created" },
600
659
  });
601
660
 
602
- // Timeout handling
661
+ // Timeout handling — explicit params.timeout wins, otherwise the configured
662
+ // default applies. 0 means unlimited.
663
+ const timeoutSecs = params.timeout ?? defaultTimeoutSecs;
603
664
  let timeoutTimer: ReturnType<typeof setTimeout> | undefined;
604
665
  const timeoutController = new AbortController();
605
- if (params.timeout) {
666
+ if (timeoutSecs > 0) {
606
667
  timeoutTimer = setTimeout(() => {
607
668
  timeoutController.abort();
608
- }, params.timeout * 1000);
669
+ }, timeoutSecs * 1000);
609
670
  }
610
671
 
611
- const combinedAbort = () => {
612
- session.abort();
613
- card.status = "error";
614
- card.endedAt = Date.now();
615
- appendMessage(card, "[aborted]");
616
- requestSubagentRender();
617
- };
672
+ // Hoisted out of the run promise so error paths can salvage partial output.
673
+ let finalText = "";
618
674
 
619
- try {
620
- const result = await new Promise<string>((resolve, reject) => {
621
- let finalText = "";
675
+ const runPromise = new Promise<string>((resolve, reject) => {
622
676
  let textDeltaBuffer = "";
623
677
  let toolcallDeltaBuffer = "";
624
678
  let lastPartialUpdateAt = 0;
@@ -768,15 +822,17 @@ async function executeSubagent(
768
822
  }
769
823
  });
770
824
 
771
- if (signal) {
772
- signal.addEventListener("abort", () => {
773
- combinedAbort();
774
- reject(new Error("Subagent was aborted"));
775
- });
776
- }
777
825
  timeoutController.signal.addEventListener("abort", () => {
778
- combinedAbort();
779
- reject(new Error(`Subagent timed out after ${params.timeout}s`));
826
+ // Abort only this subagent's own session. Its in-flight nested
827
+ // subagents receive the abort as a PARENT abort and detach instead of
828
+ // dying (see ParentAbortedError handling below), so their work is
829
+ // still written to disk.
830
+ session.abort();
831
+ card.status = "error";
832
+ card.endedAt = Date.now();
833
+ appendMessage(card, `[timed out after ${timeoutSecs}s]`);
834
+ requestSubagentRender();
835
+ reject(new Error(`Subagent timed out after ${timeoutSecs}s`));
780
836
  });
781
837
 
782
838
  session.prompt(params.task).catch((err) => {
@@ -786,30 +842,85 @@ async function executeSubagent(
786
842
  requestSubagentRender();
787
843
  reject(err);
788
844
  });
789
- });
845
+ });
790
846
 
847
+ const finalizeSuccess = async (result: string): Promise<string> => {
791
848
  if (timeoutTimer) clearTimeout(timeoutTimer);
792
849
  session.dispose();
793
850
  await flushJsonl(jsonlPath);
794
-
795
851
  const resultPath = join(outDir, "result.md");
796
852
  writeFileSync(resultPath, result, "utf-8");
853
+ return resultPath;
854
+ };
797
855
 
798
- return {
799
- content: [{ type: "text", text: `Execution succeeded. Result is in \`${resultPath}\`` }],
800
- details: { sessionId: session.sessionId, status: "completed", outputDir: outDir },
801
- };
802
- } catch (err: any) {
856
+ const finalizeError = async (err: any): Promise<{ errorPath: string; partialPath?: string }> => {
803
857
  if (timeoutTimer) clearTimeout(timeoutTimer);
804
858
  session.dispose();
805
859
  await flushJsonl(jsonlPath);
806
-
807
860
  const errorMsg = err?.message ?? String(err);
808
861
  const errorPath = join(outDir, "error.md");
809
- writeFileSync(errorPath, `# Subagent Error\n\n${errorMsg}\n`, "utf-8");
862
+ let body = `# Subagent Error\n\n${errorMsg}\n`;
863
+ let partialPath: string | undefined;
864
+ if (finalText.trim()) {
865
+ partialPath = join(outDir, "partial-result.md");
866
+ writeFileSync(partialPath, finalText, "utf-8");
867
+ body += `\nPartial output produced before the failure was saved to \`partial-result.md\`.\n`;
868
+ }
869
+ writeFileSync(errorPath, body, "utf-8");
870
+ return { errorPath, partialPath };
871
+ };
810
872
 
873
+ // A parent abort races against the run instead of killing the session, so
874
+ // a timed-out/aborted parent can no longer destroy useful child work.
875
+ let parentAbortPromise: Promise<never> | undefined;
876
+ if (signal) {
877
+ parentAbortPromise = new Promise<never>((_, reject) => {
878
+ signal.addEventListener("abort", () => reject(new ParentAbortedError()), { once: true });
879
+ });
880
+ }
881
+
882
+ try {
883
+ const result = await (parentAbortPromise ? Promise.race([runPromise, parentAbortPromise]) : runPromise);
884
+ const resultPath = await finalizeSuccess(result);
811
885
  return {
812
- content: [{ type: "text", text: `Execution failed. Detail is in \`${errorPath}\`` }],
886
+ content: [{ type: "text", text: `Execution succeeded. Result is in \`${resultPath}\`` }],
887
+ details: { sessionId: session.sessionId, status: "completed", outputDir: outDir },
888
+ };
889
+ } catch (err: any) {
890
+ if (err instanceof ParentAbortedError) {
891
+ // The parent aborted (typically because it hit its own timeout) while
892
+ // this subagent was still working. Don't kill it — detach: let it run
893
+ // to completion, still bounded by its own timeout, and write result.md
894
+ // / error.md as usual so the work can be harvested later.
895
+ appendMessage(card, "[parent aborted — continuing detached]");
896
+ requestSubagentRender();
897
+ runPromise
898
+ .then((result) => finalizeSuccess(result))
899
+ .catch((e) => finalizeError(e))
900
+ .catch(() => {});
901
+ return {
902
+ content: [
903
+ {
904
+ type: "text",
905
+ text:
906
+ "Subagent was aborted by its parent but continues running detached (its own timeout still applies). " +
907
+ `Its result will be written to \`${outDir}\` when it finishes.`,
908
+ },
909
+ ],
910
+ details: { sessionId: session.sessionId, status: "detached", outputDir: outDir },
911
+ };
912
+ }
913
+
914
+ const { errorPath, partialPath } = await finalizeError(err);
915
+ return {
916
+ content: [
917
+ {
918
+ type: "text",
919
+ text:
920
+ `Execution failed. Detail is in \`${errorPath}\`` +
921
+ (partialPath ? ` (partial output in \`${partialPath}\`)` : ""),
922
+ },
923
+ ],
813
924
  details: { sessionId: session.sessionId, status: "error", outputDir: outDir },
814
925
  };
815
926
  }
@@ -820,13 +931,15 @@ function createSubagentAgentTool(
820
931
  parentProvider: string,
821
932
  parentModel: string,
822
933
  parentCwd: string,
934
+ childDepth: number,
823
935
  ): ToolDefinition<typeof SubagentParams> {
824
936
  return {
825
937
  name: "subagent_create",
826
938
  label: "Subagent",
827
939
  description:
828
940
  "Create a subagent to perform a task. The subagent runs in-process with its own session. " +
829
- "Progress is streamed back as execution updates. Returns the final result when the subagent finishes.",
941
+ "Progress is streamed back as execution updates. Returns the final result when the subagent finishes. " +
942
+ "Nesting is limited to a configured max depth — if the limit is reached, do the work directly instead.",
830
943
  parameters: SubagentParams,
831
944
  async execute(
832
945
  toolCallId: string,
@@ -843,6 +956,7 @@ function createSubagentAgentTool(
843
956
  parentProvider,
844
957
  parentModel,
845
958
  parentCwd,
959
+ childDepth,
846
960
  );
847
961
  },
848
962
  };
@@ -856,6 +970,16 @@ export default function (pi: ExtensionAPI) {
856
970
  default: false,
857
971
  });
858
972
 
973
+ pi.registerFlag("saim-max-depth", {
974
+ description: `Max subagent nesting depth, 1-${MAX_DEPTH_HARD_LIMIT} (default ${DEFAULT_MAX_DEPTH}). Prevents runaway subagent forks.`,
975
+ type: "string",
976
+ });
977
+
978
+ pi.registerFlag("saim-timeout", {
979
+ description: `Default subagent timeout in seconds, 0 = unlimited (default ${DEFAULT_TIMEOUT_SECS}). Per-call \`timeout\` parameter overrides it.`,
980
+ type: "string",
981
+ });
982
+
859
983
  pi.on("session_start", async (_event, ctx) => {
860
984
  currentCtx = ctx;
861
985
  mainSessionId = ctx.sessionManager.getSessionId?.() ?? `session-${Date.now()}`;
@@ -874,9 +998,70 @@ export default function (pi: ExtensionAPI) {
874
998
  overlayEnabled = false;
875
999
  }
876
1000
 
1001
+ // Apply --saim-max-depth / --saim-timeout flags if present
1002
+ const depthFlag = parseIntSetting(pi.getFlag("saim-max-depth"));
1003
+ if (depthFlag !== undefined) {
1004
+ if (Number.isInteger(depthFlag) && depthFlag >= 1 && depthFlag <= MAX_DEPTH_HARD_LIMIT) {
1005
+ maxSubagentDepth = depthFlag;
1006
+ } else {
1007
+ ctx.ui.notify(
1008
+ `Invalid --saim-max-depth (expected 1-${MAX_DEPTH_HARD_LIMIT}); keeping ${maxSubagentDepth}`,
1009
+ "warning",
1010
+ );
1011
+ }
1012
+ }
1013
+ const timeoutFlag = parseIntSetting(pi.getFlag("saim-timeout"));
1014
+ if (timeoutFlag !== undefined) {
1015
+ if (Number.isInteger(timeoutFlag) && timeoutFlag >= 0) {
1016
+ defaultTimeoutSecs = timeoutFlag;
1017
+ } else {
1018
+ ctx.ui.notify(
1019
+ `Invalid --saim-timeout (expected seconds >= 0); keeping ${defaultTimeoutSecs}`,
1020
+ "warning",
1021
+ );
1022
+ }
1023
+ }
1024
+
877
1025
  requestSubagentRender();
878
1026
  });
879
1027
 
1028
+ pi.registerCommand("saim-max-depth", {
1029
+ description: `Show or set max subagent nesting depth (1-${MAX_DEPTH_HARD_LIMIT}, default ${DEFAULT_MAX_DEPTH}). Usage: /saim-max-depth [n]`,
1030
+ handler: async (args, ctx) => {
1031
+ const arg = (args ?? "").trim();
1032
+ if (arg === "") {
1033
+ ctx.ui.notify(`Max subagent nesting depth: ${maxSubagentDepth}`, "info");
1034
+ return;
1035
+ }
1036
+ const n = Number(arg);
1037
+ if (!Number.isInteger(n) || n < 1 || n > MAX_DEPTH_HARD_LIMIT) {
1038
+ ctx.ui.notify(`Provide an integer between 1 and ${MAX_DEPTH_HARD_LIMIT}.`, "warning");
1039
+ return;
1040
+ }
1041
+ maxSubagentDepth = n;
1042
+ ctx.ui.notify(`Max subagent nesting depth set to ${n} (applies to newly created subagents)`, "info");
1043
+ },
1044
+ });
1045
+
1046
+ pi.registerCommand("saim-timeout", {
1047
+ description: `Show or set the default subagent timeout in seconds (0 = unlimited, default ${DEFAULT_TIMEOUT_SECS}). Usage: /saim-timeout [seconds]`,
1048
+ handler: async (args, ctx) => {
1049
+ const arg = (args ?? "").trim();
1050
+ const fmt = (secs: number) => (secs === 0 ? "unlimited" : `${secs}s`);
1051
+ if (arg === "") {
1052
+ ctx.ui.notify(`Default subagent timeout: ${fmt(defaultTimeoutSecs)}`, "info");
1053
+ return;
1054
+ }
1055
+ const n = Number(arg);
1056
+ if (!Number.isInteger(n) || n < 0) {
1057
+ ctx.ui.notify("Provide a non-negative integer number of seconds (0 = unlimited).", "warning");
1058
+ return;
1059
+ }
1060
+ defaultTimeoutSecs = n;
1061
+ ctx.ui.notify(`Default subagent timeout set to ${fmt(n)} (applies to newly created subagents)`, "info");
1062
+ },
1063
+ });
1064
+
880
1065
  pi.registerCommand("saim-clear-tui-overlay", {
881
1066
  description: "Clear subagent TUI cards and close any open detail overlay",
882
1067
  handler: async (_args, ctx) => {
@@ -1012,7 +1197,8 @@ export default function (pi: ExtensionAPI) {
1012
1197
  label: "Subagent",
1013
1198
  description:
1014
1199
  "Create a subagent to perform a task. The subagent runs in-process with its own session. " +
1015
- "Progress is streamed back as execution updates. Returns the final result when the subagent finishes.",
1200
+ "Progress is streamed back as execution updates. Returns the final result when the subagent finishes. " +
1201
+ "Nesting is limited to a configured max depth — if the limit is reached, do the work directly instead.",
1016
1202
  parameters: SubagentParams,
1017
1203
 
1018
1204
  async execute(toolCallId, params, signal, onUpdate, ctx) {
@@ -1029,6 +1215,7 @@ export default function (pi: ExtensionAPI) {
1029
1215
  providerName,
1030
1216
  modelId,
1031
1217
  cwd,
1218
+ 1,
1032
1219
  );
1033
1220
  },
1034
1221
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-subagent-in-memory",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
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",