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 +33 -11
- package/extensions/index.ts +230 -43
- package/package.json +1 -1
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
|
|
65
|
-
│ └── result.md
|
|
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
|
-
│
|
|
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
|
-
### 🎛️
|
|
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
|
-
### 🚩
|
|
108
|
+
### 🚩 CLI Flags
|
|
91
109
|
|
|
92
|
-
|
|
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"
|
package/extensions/index.ts
CHANGED
|
@@ -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
|
-
"
|
|
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
|
|
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.
|
|
587
|
+
const resolvedModel = services.modelRuntime.getModel(providerName, modelId);
|
|
535
588
|
if (!resolvedModel) {
|
|
536
|
-
const providerModels = services.
|
|
537
|
-
.
|
|
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:
|
|
563
|
-
|
|
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 (
|
|
666
|
+
if (timeoutSecs > 0) {
|
|
606
667
|
timeoutTimer = setTimeout(() => {
|
|
607
668
|
timeoutController.abort();
|
|
608
|
-
},
|
|
669
|
+
}, timeoutSecs * 1000);
|
|
609
670
|
}
|
|
610
671
|
|
|
611
|
-
|
|
612
|
-
|
|
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
|
-
|
|
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
|
-
|
|
779
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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