pi-better-subagents 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +420 -0
- package/batch.mjs +208 -0
- package/capacity.mjs +112 -0
- package/completion.mjs +165 -0
- package/completion.ts +11 -0
- package/config.json +14 -0
- package/config.ts +104 -0
- package/extensions.mjs +147 -0
- package/extensions.ts +19 -0
- package/finalization.ts +145 -0
- package/git-remotes.ts +413 -0
- package/git-workspace.ts +430 -0
- package/health-observation.ts +670 -0
- package/health-surface.mjs +276 -0
- package/health.ts +303 -0
- package/index.ts +1235 -0
- package/lifecycle.ts +333 -0
- package/list.mjs +123 -0
- package/list.ts +17 -0
- package/navigator.mjs +1188 -0
- package/navigator.ts +38 -0
- package/package.json +43 -0
- package/parse.ts +1144 -0
- package/registry.ts +236 -0
- package/sandbox.ts +164 -0
- package/spawn.ts +78 -0
- package/stop.ts +155 -0
- package/tools.ts +399 -0
- package/widget.mjs +218 -0
- package/widget.ts +28 -0
package/index.ts
ADDED
|
@@ -0,0 +1,1235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-better-subagents — Claude Code-style async subagents for pi.
|
|
3
|
+
*
|
|
4
|
+
* Core semantic: launching a subagent IS the deliverable. `subagent_spawn`
|
|
5
|
+
* starts a detached `pi -p` child and returns immediately with a run id; the
|
|
6
|
+
* foreground session stays free for the human while it runs. When the child
|
|
7
|
+
* finishes, its RESULT is posted back into the session (delivered as a followUp
|
|
8
|
+
* so it never cuts into work in progress). The foreground is never BLOCKED on a
|
|
9
|
+
* wait/poll loop — it's only nudged once, at completion, with the answer.
|
|
10
|
+
*
|
|
11
|
+
* launch is the result · completion posts back · the foreground never blocks
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { execSync } from "node:child_process";
|
|
15
|
+
import { writeFileSync, mkdirSync, statSync } from "node:fs";
|
|
16
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
17
|
+
import { CustomEditor } from "@earendil-works/pi-coding-agent";
|
|
18
|
+
import { matchesKey, Key, truncateToWidth } from "@earendil-works/pi-tui";
|
|
19
|
+
import { Type } from "@earendil-works/pi-ai";
|
|
20
|
+
import {
|
|
21
|
+
CLOSE_CONFIRM_STATUS_KEY,
|
|
22
|
+
NAVIGATOR_STATUS_KEY,
|
|
23
|
+
disposeBackgroundWorkNavigator,
|
|
24
|
+
ensureBackgroundWorkNavigator,
|
|
25
|
+
isNavigatorUiAvailable,
|
|
26
|
+
refreshBackgroundWorkNavigator,
|
|
27
|
+
registerBackgroundWorkProvider,
|
|
28
|
+
type BackgroundWorkDetail,
|
|
29
|
+
type BackgroundWorkProvider,
|
|
30
|
+
type BackgroundWorkRow,
|
|
31
|
+
} from "../navigator/index.ts";
|
|
32
|
+
import { spawnDetached, type SpawnResult } from "./spawn.ts";
|
|
33
|
+
import { parseRun, type Usage } from "./parse.ts";
|
|
34
|
+
import { finalizeRun as finalizeRunCore } from "./finalization.ts";
|
|
35
|
+
import { loadConfig, normalizeTools, resolveExtensionPath, SAFE_DEFAULT_TOOLS, SAFE_CLEAN_TOOLS, DEFAULT_MAX_CONCURRENT } from "./config.ts";
|
|
36
|
+
import { resolveExtensions, extensionArgs } from "./extensions.ts";
|
|
37
|
+
import { maybeBuildSandboxCommand } from "./sandbox.ts";
|
|
38
|
+
import { resolveSubagentWorkspace } from "./git-workspace.ts";
|
|
39
|
+
import { homedir } from "node:os";
|
|
40
|
+
import { join } from "node:path";
|
|
41
|
+
import {
|
|
42
|
+
sessionsDir,
|
|
43
|
+
runDir,
|
|
44
|
+
logPathFor,
|
|
45
|
+
promptPathFor,
|
|
46
|
+
nextRunId,
|
|
47
|
+
writeMeta,
|
|
48
|
+
readMeta,
|
|
49
|
+
listMetas,
|
|
50
|
+
effectiveStatus,
|
|
51
|
+
ownedByThisParent,
|
|
52
|
+
navigatorVisibleRuns,
|
|
53
|
+
isDismissed,
|
|
54
|
+
dismissRun,
|
|
55
|
+
type RunMeta,
|
|
56
|
+
type RunCallbackOrigin,
|
|
57
|
+
} from "./registry.ts";
|
|
58
|
+
import {
|
|
59
|
+
captureProcessIdentity,
|
|
60
|
+
extractChildEventFactsFromLog,
|
|
61
|
+
loadHealthThresholdsFromConfig,
|
|
62
|
+
needsMonitoring,
|
|
63
|
+
observeRunHealth,
|
|
64
|
+
realProcessProbe,
|
|
65
|
+
reconcileRun,
|
|
66
|
+
type ChildEventFacts,
|
|
67
|
+
type HealthObservation,
|
|
68
|
+
type ProcessProbe,
|
|
69
|
+
type RawLogDiagnostic,
|
|
70
|
+
} from "./health.ts";
|
|
71
|
+
import {
|
|
72
|
+
assignBatchJobNames,
|
|
73
|
+
formatBatchLaunchResponse,
|
|
74
|
+
mergeJobOptions,
|
|
75
|
+
nextBatchId,
|
|
76
|
+
planBatchLaunches,
|
|
77
|
+
validateBatchPlan,
|
|
78
|
+
} from "./batch.mjs";
|
|
79
|
+
import {
|
|
80
|
+
formatCapacityRejectMessage,
|
|
81
|
+
getSharedCapacityGate,
|
|
82
|
+
} from "./capacity.mjs";
|
|
83
|
+
import { buildHealthCallbackDelivery } from "./completion.ts";
|
|
84
|
+
import {
|
|
85
|
+
text,
|
|
86
|
+
subagentListTool,
|
|
87
|
+
subagentOutputTool,
|
|
88
|
+
subagentResultTool,
|
|
89
|
+
subagentStopTool,
|
|
90
|
+
} from "./tools.ts";
|
|
91
|
+
import { stopRun } from "./stop.ts";
|
|
92
|
+
import {
|
|
93
|
+
WIDGET_CLEAR,
|
|
94
|
+
fmtElapsed,
|
|
95
|
+
fmtSpend,
|
|
96
|
+
shortModel,
|
|
97
|
+
isSpendCacheFresh,
|
|
98
|
+
resolveHealthLogExtraction,
|
|
99
|
+
} from "./widget.ts";
|
|
100
|
+
import {
|
|
101
|
+
executeNavigatorClose,
|
|
102
|
+
buildNavigatorRows,
|
|
103
|
+
buildNavigatorDetail,
|
|
104
|
+
} from "./navigator.ts";
|
|
105
|
+
|
|
106
|
+
/** The tools this extension registers — excluded from children by default so a
|
|
107
|
+
* subagent cannot recursively spawn more subagents unless explicitly allowed. */
|
|
108
|
+
const SUBAGENT_TOOLS = [
|
|
109
|
+
"subagent_spawn",
|
|
110
|
+
"subagent_spawn_batch",
|
|
111
|
+
"subagent_list",
|
|
112
|
+
"subagent_output",
|
|
113
|
+
"subagent_stop",
|
|
114
|
+
"subagent_result",
|
|
115
|
+
];
|
|
116
|
+
|
|
117
|
+
// ---- retired live status widget ------------------------------------------
|
|
118
|
+
//
|
|
119
|
+
// The shared background-work navigator owns the active subagent list. This
|
|
120
|
+
// legacy `subagents` widget key is now clear-only so users do not see the same
|
|
121
|
+
// run twice (`background work · N` plus `Subagents · N running`). Pure widget
|
|
122
|
+
// helpers remain for compatibility tests and older render contracts.
|
|
123
|
+
|
|
124
|
+
/** Freshest UI-bearing context, captured from session_start / tool calls. */
|
|
125
|
+
let uiCtx: ExtensionContext | undefined;
|
|
126
|
+
let activeCallbackOrigin: RunCallbackOrigin | undefined;
|
|
127
|
+
let ticker: ReturnType<typeof setInterval> | undefined;
|
|
128
|
+
let widgetNavActive = false;
|
|
129
|
+
let widgetNavSelectedId: string | undefined;
|
|
130
|
+
|
|
131
|
+
type SpendSnap = {
|
|
132
|
+
usage: Usage;
|
|
133
|
+
tool: string | null;
|
|
134
|
+
refreshedAt: number;
|
|
135
|
+
logSize: number;
|
|
136
|
+
};
|
|
137
|
+
/** Per-run spend/tool cache for the UI hot path. */
|
|
138
|
+
const spendCache = new Map<string, SpendSnap>();
|
|
139
|
+
|
|
140
|
+
type HealthLogSnap = {
|
|
141
|
+
facts: ChildEventFacts;
|
|
142
|
+
rawLog: RawLogDiagnostic;
|
|
143
|
+
logSize: number;
|
|
144
|
+
mtimeMs?: number;
|
|
145
|
+
};
|
|
146
|
+
/** Per-run health-log parse cache — size/mtime gated (no full reparse every tick). */
|
|
147
|
+
const healthLogCache = new Map<string, HealthLogSnap>();
|
|
148
|
+
|
|
149
|
+
function logStatOf(id: string): { size: number; mtimeMs?: number } {
|
|
150
|
+
try {
|
|
151
|
+
const st = statSync(logPathFor(id));
|
|
152
|
+
return { size: st.size, mtimeMs: Math.trunc(st.mtimeMs) };
|
|
153
|
+
} catch {
|
|
154
|
+
return { size: 0 };
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function logSizeOf(id: string): number {
|
|
159
|
+
return logStatOf(id).size;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function callbackOriginFromContext(ctx: ExtensionContext): RunCallbackOrigin {
|
|
163
|
+
let sessionId: string | undefined;
|
|
164
|
+
try {
|
|
165
|
+
sessionId = ctx.sessionManager?.getSessionId();
|
|
166
|
+
} catch {
|
|
167
|
+
sessionId = undefined;
|
|
168
|
+
}
|
|
169
|
+
return { cwd: ctx.cwd, sessionId };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function callbackSuppressionReason(meta: RunMeta, active: RunCallbackOrigin | undefined = activeCallbackOrigin): string | undefined {
|
|
173
|
+
const origin = meta.callbackOrigin;
|
|
174
|
+
if (origin) {
|
|
175
|
+
if (!active) return "active session identity is unavailable";
|
|
176
|
+
if (origin.cwd !== active.cwd) return `origin cwd ${origin.cwd} does not match active cwd ${active.cwd}`;
|
|
177
|
+
if (origin.sessionId && origin.sessionId !== active.sessionId) {
|
|
178
|
+
return `origin session ${origin.sessionId} does not match active session ${active.sessionId ?? "unknown"}`;
|
|
179
|
+
}
|
|
180
|
+
return undefined;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (active && meta.cwd !== active.cwd) {
|
|
184
|
+
return `legacy run cwd ${meta.cwd} does not match active cwd ${active.cwd}`;
|
|
185
|
+
}
|
|
186
|
+
return undefined;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function belongsToActiveNavigatorSession(meta: RunMeta): boolean {
|
|
190
|
+
const active = activeCallbackOrigin;
|
|
191
|
+
if (!active) return false;
|
|
192
|
+
const origin = meta.callbackOrigin;
|
|
193
|
+
if (origin) {
|
|
194
|
+
if (origin.cwd !== active.cwd) return false;
|
|
195
|
+
if (origin.sessionId || active.sessionId) return origin.sessionId === active.sessionId;
|
|
196
|
+
return true;
|
|
197
|
+
}
|
|
198
|
+
if (active.sessionId) return false;
|
|
199
|
+
return meta.cwd === active.cwd;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function markCompletionCallbackSuppressed(id: string, reason: string, now: number = Date.now()): void {
|
|
203
|
+
const meta = readMeta(id);
|
|
204
|
+
if (!meta || meta.completionCallbackSuppressedAt !== undefined) return;
|
|
205
|
+
meta.completionCallbackSuppressedAt = now;
|
|
206
|
+
meta.completionCallbackSuppressedReason = reason;
|
|
207
|
+
writeMeta(meta);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function markHealthCallbackSuppressed(meta: RunMeta, status: "orphaned" | "lost", reason: string, now: number): void {
|
|
211
|
+
if (status === "orphaned") {
|
|
212
|
+
if (meta.orphanedCallbackSuppressedAt !== undefined) return;
|
|
213
|
+
meta.orphanedCallbackSuppressedAt = now;
|
|
214
|
+
meta.orphanedCallbackSuppressedReason = reason;
|
|
215
|
+
} else {
|
|
216
|
+
if (meta.lostCallbackSuppressedAt !== undefined) return;
|
|
217
|
+
meta.lostCallbackSuppressedAt = now;
|
|
218
|
+
meta.lostCallbackSuppressedReason = reason;
|
|
219
|
+
}
|
|
220
|
+
writeMeta(meta);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function isHealthCallbackHandled(meta: RunMeta, status: "orphaned" | "lost"): boolean {
|
|
224
|
+
return status === "orphaned"
|
|
225
|
+
? meta.orphanedCallbackSentAt !== undefined || meta.orphanedCallbackSuppressedAt !== undefined
|
|
226
|
+
: meta.lostCallbackSentAt !== undefined || meta.lostCallbackSuppressedAt !== undefined;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Refresh spend/tool for a run only when the cache is stale or the log grew. */
|
|
230
|
+
function spendFor(id: string, now: number): { usage: Usage; tool: string | null } {
|
|
231
|
+
const logSize = logSizeOf(id);
|
|
232
|
+
const cached = spendCache.get(id);
|
|
233
|
+
if (isSpendCacheFresh(cached, now, logSize)) {
|
|
234
|
+
return { usage: cached!.usage, tool: cached!.tool };
|
|
235
|
+
}
|
|
236
|
+
const r = parseRun(id);
|
|
237
|
+
const snap: SpendSnap = {
|
|
238
|
+
usage: r.usage,
|
|
239
|
+
tool: r.toolCalls.length ? r.toolCalls[r.toolCalls.length - 1]! : null,
|
|
240
|
+
refreshedAt: now,
|
|
241
|
+
logSize,
|
|
242
|
+
};
|
|
243
|
+
spendCache.set(id, snap);
|
|
244
|
+
return { usage: snap.usage, tool: snap.tool };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Observe health for a widget/navigator row. Best-effort; never throws into the tick.
|
|
249
|
+
* Full log parse is gated by size/mtime so the 1 Hz frame does not re-read and
|
|
250
|
+
* reparse every complete log when nothing changed (#67).
|
|
251
|
+
*
|
|
252
|
+
* When `displayStatus` is omitted, uses durable `meta.status`.
|
|
253
|
+
* Navigator detail/list pass `effectiveStatus(meta)` so process liveness cannot
|
|
254
|
+
* say "supervised" while the UI shows transient "exited" (#69).
|
|
255
|
+
*/
|
|
256
|
+
function observeWidgetHealth(
|
|
257
|
+
meta: RunMeta,
|
|
258
|
+
now: number,
|
|
259
|
+
displayStatus?: RunMeta["status"] | "exited",
|
|
260
|
+
): HealthObservation | undefined {
|
|
261
|
+
try {
|
|
262
|
+
const { size: logSize, mtimeMs } = logStatOf(meta.id);
|
|
263
|
+
const cached = healthLogCache.get(meta.id);
|
|
264
|
+
const resolved = resolveHealthLogExtraction(
|
|
265
|
+
cached,
|
|
266
|
+
{ logSize, mtimeMs },
|
|
267
|
+
() => extractChildEventFactsFromLog(meta.id, { now }),
|
|
268
|
+
);
|
|
269
|
+
if (!resolved.hit) {
|
|
270
|
+
healthLogCache.set(meta.id, {
|
|
271
|
+
facts: resolved.facts as ChildEventFacts,
|
|
272
|
+
rawLog: resolved.rawLog as RawLogDiagnostic,
|
|
273
|
+
logSize,
|
|
274
|
+
mtimeMs,
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
const status = displayStatus ?? meta.status;
|
|
278
|
+
return observeRunHealth({
|
|
279
|
+
// Prefer caller-supplied effective/display status (navigator detail)
|
|
280
|
+
// so liveness cannot say "supervised" while the UI shows "exited".
|
|
281
|
+
status,
|
|
282
|
+
now,
|
|
283
|
+
facts: resolved.facts as ChildEventFacts,
|
|
284
|
+
rawLog: resolved.rawLog as RawLogDiagnostic,
|
|
285
|
+
thresholds: loadHealthThresholdsFromConfig(),
|
|
286
|
+
startedAt: meta.startedAt,
|
|
287
|
+
});
|
|
288
|
+
} catch {
|
|
289
|
+
return undefined;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function syncWidgetNavSelection(running: RunMeta[]): void {
|
|
294
|
+
if (!widgetNavActive) return;
|
|
295
|
+
if (running.length === 0) {
|
|
296
|
+
widgetNavActive = false;
|
|
297
|
+
widgetNavSelectedId = undefined;
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
if (!widgetNavSelectedId || !running.some((m) => m.id === widgetNavSelectedId)) {
|
|
301
|
+
// Start on the row nearest the input line; Down returns to input.
|
|
302
|
+
widgetNavSelectedId = running[running.length - 1]?.id;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Clear the retired legacy subagent widget and refresh the shared navigator.
|
|
308
|
+
* The shared background-work navigator is now the only list surface; keeping
|
|
309
|
+
* this path as clear-only prevents the old `Subagents · N running` widget from
|
|
310
|
+
* duplicating the same run below `background work · N`.
|
|
311
|
+
*/
|
|
312
|
+
function renderWidget(): void {
|
|
313
|
+
const ctx = uiCtx;
|
|
314
|
+
if (!ctx || !ctx.hasUI) return;
|
|
315
|
+
updateNavigatorFooter(ctx);
|
|
316
|
+
try { ctx.ui.setWidget("subagents", WIDGET_CLEAR); } catch { /* ignore */ }
|
|
317
|
+
spendCache.clear();
|
|
318
|
+
healthLogCache.clear();
|
|
319
|
+
stopTicker();
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** Clear the retired widget if a UI is present. */
|
|
323
|
+
function ensureTicker(): void {
|
|
324
|
+
if (!uiCtx?.hasUI) return;
|
|
325
|
+
renderWidget();
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function stopTicker(): void {
|
|
329
|
+
if (ticker) { clearInterval(ticker); ticker = undefined; }
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// ---- periodic health reconciliation (#63) --------------------------------
|
|
333
|
+
//
|
|
334
|
+
// Reconciles durable supervision status for current-parent running/orphaned
|
|
335
|
+
// runs (process-group-only, ADR 0002): a run whose child is gone but whose
|
|
336
|
+
// captured process group still has live members becomes durable non-terminal
|
|
337
|
+
// `orphaned`; a run with no credible process-group evidence becomes durable
|
|
338
|
+
// terminal `lost`. Escaped/reparented descendants are out of contract.
|
|
339
|
+
// Reconciliation never kills anything; it only writes truth. The ticker
|
|
340
|
+
// exists only while current-parent running/orphaned work needs monitoring.
|
|
341
|
+
|
|
342
|
+
/** How often supervision is reconciled. Independent of the 1 Hz widget tick. */
|
|
343
|
+
const HEALTH_TICK_MS = 15_000;
|
|
344
|
+
let healthTicker: ReturnType<typeof setInterval> | undefined;
|
|
345
|
+
/** ExtensionAPI retained so health transitions can deliver coordinator follow-ups (#65). */
|
|
346
|
+
let healthPi: ExtensionAPI | undefined;
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Deliver a durable, deduped coordinator follow-up for orphaned/lost (#65).
|
|
350
|
+
*
|
|
351
|
+
* Markers live on RunMeta so reloads and repeated health ticks never re-fire.
|
|
352
|
+
* A marker means successful handoff only: written after sendMessage returns, or
|
|
353
|
+
* after intentionally suppressing the model path under callback:false. A failed
|
|
354
|
+
* or crashed delivery leaves the marker unset so reload/recovery can retry.
|
|
355
|
+
* `callback:false` suppresses the model message only — human ui.notify is
|
|
356
|
+
* handled by the caller. Uses the same non-interrupting followUp mechanics as
|
|
357
|
+
* completion, with distinct ATTENTION wording from buildHealthCallbackDelivery.
|
|
358
|
+
*/
|
|
359
|
+
function deliverHealthCallback(pi: ExtensionAPI | undefined, meta: RunMeta, status: "orphaned" | "lost", now: number): void {
|
|
360
|
+
if (!pi) return;
|
|
361
|
+
if (isHealthCallbackHandled(meta, status)) return;
|
|
362
|
+
|
|
363
|
+
const suppressionReason = callbackSuppressionReason(meta);
|
|
364
|
+
if (suppressionReason) {
|
|
365
|
+
markHealthCallbackSuppressed(meta, status, suppressionReason, now);
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const callback = meta.callback !== false;
|
|
370
|
+
const label = meta.name ? `${meta.name} (${meta.id})` : meta.id;
|
|
371
|
+
const delivery = buildHealthCallbackDelivery({ id: meta.id, label, status, callback });
|
|
372
|
+
if (!delivery) {
|
|
373
|
+
// callback:false — model follow-up suppressed; mark handled so recovery
|
|
374
|
+
// does not spin forever. Human notify remains the caller's job.
|
|
375
|
+
if (status === "orphaned") meta.orphanedCallbackSentAt = now;
|
|
376
|
+
else meta.lostCallbackSentAt = now;
|
|
377
|
+
writeMeta(meta);
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
try {
|
|
381
|
+
pi.sendMessage(
|
|
382
|
+
{ customType: "subagent-health", content: delivery.content, display: true },
|
|
383
|
+
delivery.options,
|
|
384
|
+
);
|
|
385
|
+
} catch {
|
|
386
|
+
// Handoff failed — leave marker unset so a later tick/reload can retry.
|
|
387
|
+
// Never let a delivery failure break the health ticker.
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
// Marker = successful handoff (sendMessage returned), not mere attempt.
|
|
391
|
+
if (status === "orphaned") meta.orphanedCallbackSentAt = now;
|
|
392
|
+
else meta.lostCallbackSentAt = now;
|
|
393
|
+
writeMeta(meta);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** One reconciliation + durable health-callback recovery pass. */
|
|
397
|
+
function reconcileHealth(): void {
|
|
398
|
+
const ctx = uiCtx;
|
|
399
|
+
const pi = healthPi;
|
|
400
|
+
for (const summary of listMetas()) {
|
|
401
|
+
if (!ownedByThisParent(summary)) continue;
|
|
402
|
+
// running/orphaned: process reconcile. lost: durable callback recovery only.
|
|
403
|
+
if (summary.status !== "running" && summary.status !== "orphaned" && summary.status !== "lost") continue;
|
|
404
|
+
// Re-read under the id: finalizeRun / subagent_stop may have written a
|
|
405
|
+
// terminal status since listMetas() snapshotted.
|
|
406
|
+
const meta = readMeta(summary.id);
|
|
407
|
+
if (!meta) continue;
|
|
408
|
+
if (meta.status !== "running" && meta.status !== "orphaned" && meta.status !== "lost") continue;
|
|
409
|
+
const now = Date.now();
|
|
410
|
+
|
|
411
|
+
if (meta.status === "running" || meta.status === "orphaned") {
|
|
412
|
+
const result = reconcileRun(meta, realProcessProbe, now);
|
|
413
|
+
if (result.changed) {
|
|
414
|
+
Object.assign(meta, result.patch, { status: result.status });
|
|
415
|
+
writeMeta(meta);
|
|
416
|
+
if (result.transition) {
|
|
417
|
+
// Human-visible health (always) on fresh transitions.
|
|
418
|
+
if (!callbackSuppressionReason(meta)) {
|
|
419
|
+
const label = meta.name ? `${meta.name} (${meta.id})` : meta.id;
|
|
420
|
+
const note = result.status === "orphaned"
|
|
421
|
+
? `Subagent ${label} lost supervision — related processes may still be alive (orphaned).`
|
|
422
|
+
: `Subagent ${label} is lost — no related process remains and no terminal result was observed.`;
|
|
423
|
+
try { ctx?.ui.notify(note, "warning"); } catch { /* ignore */ }
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// Durable recovery independent of a fresh transition: any current
|
|
430
|
+
// orphaned/lost without a successful handoff marker must eventually
|
|
431
|
+
// deliver exactly one coordinator follow-up (or mark callback:false).
|
|
432
|
+
if (meta.status === "orphaned" || meta.status === "lost") {
|
|
433
|
+
deliverHealthCallback(pi, meta, meta.status, now);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
// Stop existing the moment nothing current-parent needs monitoring/recovery.
|
|
437
|
+
if (!needsMonitoring(listMetas())) stopHealthTicker();
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/** Start the reconciliation loop if it isn't already running. */
|
|
441
|
+
function ensureHealthTicker(): void {
|
|
442
|
+
if (healthTicker) return;
|
|
443
|
+
healthTicker = setInterval(reconcileHealth, HEALTH_TICK_MS);
|
|
444
|
+
healthTicker.unref?.(); // never keep the process alive on our account
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function stopHealthTicker(): void {
|
|
448
|
+
if (healthTicker) { clearInterval(healthTicker); healthTicker = undefined; }
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/** Test/diagnostic seam: whether the periodic reconciliation loop is active. */
|
|
452
|
+
export function isHealthTickerActive(): boolean {
|
|
453
|
+
return healthTicker !== undefined;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* Spawn-time identity probe. Production uses the OS-backed probe; extension-
|
|
458
|
+
* level tests substitute a deterministic fake at this kernel boundary (never a
|
|
459
|
+
* mock of a first-party module) via setIdentityProbeForTests.
|
|
460
|
+
*/
|
|
461
|
+
let spawnIdentityProbe: ProcessProbe = realProcessProbe;
|
|
462
|
+
export function setIdentityProbeForTests(probe: ProcessProbe | undefined): void {
|
|
463
|
+
spawnIdentityProbe = probe ?? realProcessProbe;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// ---- minimal subagent navigator (empty-editor ←, #45) --------------------
|
|
467
|
+
// ---- subagent navigator (empty-editor ← list #45, live detail #46) --------
|
|
468
|
+
// ---- subagent navigator (list #45, detail #46, two-press close #47) -------
|
|
469
|
+
//
|
|
470
|
+
// Human-facing TUI surface. Glue points, all gated on isNavigatorUiAvailable so
|
|
471
|
+
// print/RPC sessions never see any of it:
|
|
472
|
+
// 1. footer hint `← subagents · N` via the DEFAULT footer status mechanism
|
|
473
|
+
// (setStatus — the full footer is never replaced);
|
|
474
|
+
// 2. an editor wrapper that intercepts bare ← only when the editor is empty
|
|
475
|
+
// and at least one non-dismissed current-parent run is running,
|
|
476
|
+
// delegating everything else to the wrapped
|
|
477
|
+
// editor (composition via navigator.mjs, tested with fakes);
|
|
478
|
+
// 3. a focused overlay (ctx.ui.custom(..., { overlay: true })) listing the
|
|
479
|
+
// #44 navigatorVisibleRuns newest first, with Enter → live detail view
|
|
480
|
+
// that refreshes once per second (#46) and two-press `x` Close (#47).
|
|
481
|
+
// Detail + close-arm timers dispose on back, Escape, overlay close,
|
|
482
|
+
// selection change, list↔detail return, and session_shutdown.
|
|
483
|
+
|
|
484
|
+
let unregisterSubagentProvider: (() => void) | undefined;
|
|
485
|
+
const TERMINAL_NAVIGATOR_RETENTION_MS = 30_000;
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* Observe health for a navigator row/detail (#69). Reuses the size/mtime-gated
|
|
489
|
+
* log cache so the overlay refresh does not reparse every complete log on each
|
|
490
|
+
* paint. Passes effective/display status so detail
|
|
491
|
+
* liveness matches the status line for legacy dead-running metadata.
|
|
492
|
+
* Best-effort; never throws into the TUI.
|
|
493
|
+
*/
|
|
494
|
+
function observeNavigatorHealth(meta: RunMeta, now: number = Date.now()): HealthObservation | undefined {
|
|
495
|
+
return observeWidgetHealth(meta, now, effectiveStatus(meta));
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/** Rows for the overlay: visible current-parent runs, newest first (#44 seam). */
|
|
499
|
+
function navigatorRows() {
|
|
500
|
+
const now = Date.now();
|
|
501
|
+
return buildNavigatorRows(sessionVisibleNavigatorRuns(), {
|
|
502
|
+
effectiveStatus,
|
|
503
|
+
shortModel,
|
|
504
|
+
fmtElapsed,
|
|
505
|
+
now,
|
|
506
|
+
spendFor: (m: RunMeta) => {
|
|
507
|
+
const snap = spendFor(m.id, now);
|
|
508
|
+
return fmtSpend(snap.usage);
|
|
509
|
+
},
|
|
510
|
+
toolFor: (m: RunMeta) => {
|
|
511
|
+
const snap = spendFor(m.id, now);
|
|
512
|
+
return snap.tool ?? "";
|
|
513
|
+
},
|
|
514
|
+
// Effort is shown when available on metadata; Pi does not always expose it.
|
|
515
|
+
effortFor: (m: RunMeta) => {
|
|
516
|
+
const any = m as RunMeta & { effort?: string; modelEffort?: string };
|
|
517
|
+
return any.effort ?? any.modelEffort;
|
|
518
|
+
},
|
|
519
|
+
healthFor: (m: RunMeta) => observeNavigatorHealth(m, now),
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/** Runs that should advertise/open the left-arrow navigator affordance. */
|
|
524
|
+
function navigatorRunningRuns(): RunMeta[] {
|
|
525
|
+
return sessionVisibleNavigatorRuns().filter((m) => effectiveStatus(m) === "running");
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function navigatorRunningCount(): number {
|
|
529
|
+
return navigatorRunningRuns().length;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function sessionVisibleNavigatorRuns(): RunMeta[] {
|
|
533
|
+
const now = Date.now();
|
|
534
|
+
return navigatorVisibleRuns(listMetas())
|
|
535
|
+
.filter(belongsToActiveNavigatorSession)
|
|
536
|
+
.filter((m) => !isExpiredTerminalNavigatorRun(m, now));
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function isExpiredTerminalNavigatorRun(meta: RunMeta, now: number): boolean {
|
|
540
|
+
const status = effectiveStatus(meta);
|
|
541
|
+
if (!isTerminalNavigatorStatus(status)) return false;
|
|
542
|
+
const endedAt = meta.endedAt ?? meta.lostAt;
|
|
543
|
+
return typeof endedAt === "number" && now - endedAt >= TERMINAL_NAVIGATOR_RETENTION_MS;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function isTerminalNavigatorStatus(status: string): boolean {
|
|
547
|
+
return status !== "running" && status !== "orphaned";
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/** Live detail snapshot for one run (registry + log parse + health). */
|
|
551
|
+
function navigatorDetail(id: string) {
|
|
552
|
+
const now = Date.now();
|
|
553
|
+
return buildNavigatorDetail(id, {
|
|
554
|
+
readMeta,
|
|
555
|
+
effectiveStatus,
|
|
556
|
+
parseRun,
|
|
557
|
+
shortModel,
|
|
558
|
+
fmtElapsed,
|
|
559
|
+
fmtSpend,
|
|
560
|
+
now,
|
|
561
|
+
effortFor: (m: RunMeta) => {
|
|
562
|
+
const any = m as RunMeta & { effort?: string; modelEffort?: string };
|
|
563
|
+
return any.effort ?? any.modelEffort;
|
|
564
|
+
},
|
|
565
|
+
healthFor: (m: RunMeta) => observeNavigatorHealth(m, now),
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/** Shared #44 stop+dismiss path used by navigator Close (#47). */
|
|
570
|
+
function navigatorCloseRun(id: string) {
|
|
571
|
+
return executeNavigatorClose(id, {
|
|
572
|
+
readMeta,
|
|
573
|
+
effectiveStatus,
|
|
574
|
+
stopRun,
|
|
575
|
+
dismissRun,
|
|
576
|
+
});
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/** Publish/clear the Close confirmation footer hint (TUI only). */
|
|
580
|
+
function publishCloseConfirmHint(ctx: ExtensionContext, hint: string | null): void {
|
|
581
|
+
if (!isNavigatorUiAvailable(ctx)) return;
|
|
582
|
+
try {
|
|
583
|
+
ctx.ui.setStatus(CLOSE_CONFIRM_STATUS_KEY, hint ?? undefined);
|
|
584
|
+
} catch { /* ignore */ }
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
function statusTone(status: string): BackgroundWorkRow["statusTone"] {
|
|
588
|
+
switch (status) {
|
|
589
|
+
case "running": return "running";
|
|
590
|
+
case "completed": return "success";
|
|
591
|
+
case "failed":
|
|
592
|
+
case "lost": return "failed";
|
|
593
|
+
case "killed":
|
|
594
|
+
case "orphaned": return "warning";
|
|
595
|
+
default: return "muted";
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
function subagentWorkRows(now: number): BackgroundWorkRow[] {
|
|
600
|
+
const startedById = new Map(sessionVisibleNavigatorRuns().map((m) => [m.id, m.startedAt]));
|
|
601
|
+
return navigatorRows().map((row) => {
|
|
602
|
+
const bits = [];
|
|
603
|
+
if (row.model) bits.push(row.effort ? `${row.model} ${row.effort}` : row.model);
|
|
604
|
+
if (row.tool) bits.push(row.tool);
|
|
605
|
+
if (row.spend) bits.push(row.spend);
|
|
606
|
+
return {
|
|
607
|
+
providerId: "subagents",
|
|
608
|
+
id: row.id,
|
|
609
|
+
name: row.name,
|
|
610
|
+
model: row.model,
|
|
611
|
+
effort: row.effort,
|
|
612
|
+
tool: row.tool,
|
|
613
|
+
tokens: row.spend,
|
|
614
|
+
status: row.status,
|
|
615
|
+
statusTone: statusTone(row.status),
|
|
616
|
+
kind: "subagent",
|
|
617
|
+
elapsed: row.elapsed,
|
|
618
|
+
primary: bits.join(" · ") || "subagent run",
|
|
619
|
+
facts: row.healthFacts,
|
|
620
|
+
sortStartedAt: startedById.get(row.id) ?? now,
|
|
621
|
+
};
|
|
622
|
+
});
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function subagentWorkDetail(id: string, now: number): BackgroundWorkDetail | null {
|
|
626
|
+
const detail = navigatorDetail(id);
|
|
627
|
+
if (!detail) return null;
|
|
628
|
+
const metadata = [
|
|
629
|
+
{ label: "provider", value: "Subagents" },
|
|
630
|
+
{ label: "model", value: detail.effort ? `${detail.model} · effort ${detail.effort}` : detail.model },
|
|
631
|
+
{ label: "elapsed", value: detail.elapsed },
|
|
632
|
+
{ label: "tools", value: detail.currentTool ? `current ${detail.currentTool}` : (detail.tools || "(none)") },
|
|
633
|
+
{ label: "spend", value: detail.spend || "(none)" },
|
|
634
|
+
{ label: "pid", value: detail.pid != null ? String(detail.pid) : "-" },
|
|
635
|
+
{ label: "pgid", value: detail.pgid != null ? String(detail.pgid) : "-" },
|
|
636
|
+
];
|
|
637
|
+
return {
|
|
638
|
+
providerId: "subagents",
|
|
639
|
+
id: detail.id,
|
|
640
|
+
title: detail.name || detail.id,
|
|
641
|
+
status: detail.status,
|
|
642
|
+
statusTone: statusTone(detail.status),
|
|
643
|
+
subtitle: detail.currentTool ? `current tool ${detail.currentTool}` : undefined,
|
|
644
|
+
metadata,
|
|
645
|
+
evidence: { label: "output", text: detail.output || "(no output yet)" },
|
|
646
|
+
footerActions: [detail.status === "running" || detail.status === "orphaned" ? "x stop" : "x dismiss"],
|
|
647
|
+
};
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
function ensureSubagentProvider(): void {
|
|
651
|
+
if (unregisterSubagentProvider) return;
|
|
652
|
+
const provider: BackgroundWorkProvider = {
|
|
653
|
+
id: "subagents",
|
|
654
|
+
label: "Subagents",
|
|
655
|
+
priority: 10,
|
|
656
|
+
visibleCount: () => navigatorRunningCount(),
|
|
657
|
+
listRows: (now) => subagentWorkRows(now),
|
|
658
|
+
detail: (id, now) => subagentWorkDetail(id, now),
|
|
659
|
+
armCloseLabel: (row) => row.status === "running" || row.status === "orphaned" ? "x again to stop" : "x again to dismiss",
|
|
660
|
+
close: (id) => {
|
|
661
|
+
const outcome = navigatorCloseRun(id) as { action: string; id: string; status?: string };
|
|
662
|
+
return { ...outcome, providerId: "subagents" };
|
|
663
|
+
},
|
|
664
|
+
};
|
|
665
|
+
unregisterSubagentProvider = registerBackgroundWorkProvider(provider);
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
function selectedWidgetNavRun(): RunMeta | undefined {
|
|
669
|
+
const running = navigatorRunningRuns();
|
|
670
|
+
syncWidgetNavSelection(running);
|
|
671
|
+
return running.find((m) => m.id === widgetNavSelectedId);
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
function enterWidgetNav(ctx: ExtensionContext): void {
|
|
675
|
+
if (!isNavigatorUiAvailable(ctx)) return;
|
|
676
|
+
const running = navigatorRunningRuns();
|
|
677
|
+
if (running.length === 0) return;
|
|
678
|
+
widgetNavActive = true;
|
|
679
|
+
widgetNavSelectedId = running[running.length - 1]?.id;
|
|
680
|
+
try { renderWidget(); } catch { /* ignore */ }
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
function exitWidgetNav(): void {
|
|
684
|
+
if (!widgetNavActive) return;
|
|
685
|
+
widgetNavActive = false;
|
|
686
|
+
widgetNavSelectedId = undefined;
|
|
687
|
+
try { renderWidget(); } catch { /* ignore */ }
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
function moveWidgetNavPrevious(): void {
|
|
691
|
+
const running = navigatorRunningRuns();
|
|
692
|
+
syncWidgetNavSelection(running);
|
|
693
|
+
const idx = running.findIndex((m) => m.id === widgetNavSelectedId);
|
|
694
|
+
if (idx > 0) widgetNavSelectedId = running[idx - 1]?.id;
|
|
695
|
+
try { renderWidget(); } catch { /* ignore */ }
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
function returnWidgetNavToInput(): void {
|
|
699
|
+
const running = navigatorRunningRuns();
|
|
700
|
+
syncWidgetNavSelection(running);
|
|
701
|
+
const idx = running.findIndex((m) => m.id === widgetNavSelectedId);
|
|
702
|
+
if (idx >= 0 && idx < running.length - 1) {
|
|
703
|
+
widgetNavSelectedId = running[idx + 1]?.id;
|
|
704
|
+
try { renderWidget(); } catch { /* ignore */ }
|
|
705
|
+
return;
|
|
706
|
+
}
|
|
707
|
+
exitWidgetNav();
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
function viewWidgetNavSelection(ctx: ExtensionContext): void {
|
|
711
|
+
const selected = selectedWidgetNavRun();
|
|
712
|
+
if (!selected) return;
|
|
713
|
+
openNavigator(ctx, selected.id);
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
function stopWidgetNavSelection(ctx: ExtensionContext): void {
|
|
717
|
+
const selected = selectedWidgetNavRun();
|
|
718
|
+
if (!selected) return;
|
|
719
|
+
try { navigatorCloseRun(selected.id); } catch { /* ignore */ }
|
|
720
|
+
updateNavigatorFooter(ctx);
|
|
721
|
+
try { renderWidget(); } catch { /* ignore */ }
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
/** Open the focused navigator overlay. No-op without a UI or visible runs. */
|
|
725
|
+
function openNavigator(ctx: ExtensionContext, initialDetailId?: string): void {
|
|
726
|
+
void initialDetailId;
|
|
727
|
+
ensureNavigator(ctx);
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
/** Publish/clear the running-only `← subagents · N` footer hint (dirty-checked). */
|
|
731
|
+
function updateNavigatorFooter(ctx: ExtensionContext | undefined): void {
|
|
732
|
+
refreshBackgroundWorkNavigator(ctx);
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
/** Install the empty-editor ← wrapper once per UI (reload-safe, composable). */
|
|
736
|
+
function ensureNavigator(ctx: ExtensionContext): void {
|
|
737
|
+
if (!isNavigatorUiAvailable(ctx)) return;
|
|
738
|
+
try {
|
|
739
|
+
ensureSubagentProvider();
|
|
740
|
+
ensureBackgroundWorkNavigator(ctx, {
|
|
741
|
+
createDefaultEditor: (tui: any, theme: any, keybindings: any) =>
|
|
742
|
+
new CustomEditor(tui, theme, keybindings),
|
|
743
|
+
isOpenTrigger: (data: string) => matchesKey(data, Key.left),
|
|
744
|
+
matchKey: (data: string, keyId: string) => matchesKey(data, keyId),
|
|
745
|
+
truncate: truncateToWidth,
|
|
746
|
+
});
|
|
747
|
+
} catch { /* ignore */ }
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
/** Resolve the pi binary once per session. */
|
|
751
|
+
let cachedPi: string | undefined;
|
|
752
|
+
function resolvePiBinary(): string {
|
|
753
|
+
if (cachedPi !== undefined) return cachedPi;
|
|
754
|
+
try {
|
|
755
|
+
cachedPi = execSync("which pi", { encoding: "utf-8", timeout: 3000 }).trim();
|
|
756
|
+
} catch {
|
|
757
|
+
cachedPi = "pi";
|
|
758
|
+
}
|
|
759
|
+
return cachedPi;
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
/**
|
|
763
|
+
* Finalize a run once its child exits. Host-facing wrapper around the
|
|
764
|
+
* first-party finalizer (finalization.ts) so tests can exercise the durable
|
|
765
|
+
* path without importing the pi package.
|
|
766
|
+
*/
|
|
767
|
+
function finalizeRun(pi: ExtensionAPI, ctx: ExtensionContext, id: string, code: number | null): void {
|
|
768
|
+
// Host-facing wrapper around first-party finalizer (finalization.ts).
|
|
769
|
+
// Coherent child-exit evidence may supersede provisional orphaned/lost
|
|
770
|
+
// reconciliation; finalization.ts enforces canExitFinalize + lifecycle authority.
|
|
771
|
+
finalizeRunCore(id, code, {
|
|
772
|
+
renderWidget,
|
|
773
|
+
notify: (message, level) => {
|
|
774
|
+
try { ctx.ui.notify(message, level); } catch { /* ignore */ }
|
|
775
|
+
},
|
|
776
|
+
sendMessage: (message, options) => {
|
|
777
|
+
const meta = readMeta(id);
|
|
778
|
+
if (!meta) return;
|
|
779
|
+
const suppressionReason = callbackSuppressionReason(meta);
|
|
780
|
+
if (suppressionReason) {
|
|
781
|
+
markCompletionCallbackSuppressed(id, suppressionReason);
|
|
782
|
+
return;
|
|
783
|
+
}
|
|
784
|
+
pi.sendMessage(message, options);
|
|
785
|
+
},
|
|
786
|
+
});
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
export default function (pi: ExtensionAPI) {
|
|
790
|
+
// Capture for the health ticker (module-level); needed for orphaned/lost
|
|
791
|
+
// coordinator follow-ups that fire outside a tool-call stack (#65).
|
|
792
|
+
healthPi = pi;
|
|
793
|
+
ensureSubagentProvider();
|
|
794
|
+
|
|
795
|
+
type SpawnParams = {
|
|
796
|
+
prompt: string; name?: string; model?: string; tools?: string;
|
|
797
|
+
exclude_tools?: string; clean?: boolean; sandbox?: boolean;
|
|
798
|
+
sandbox_dir?: string; callback?: boolean; cwd?: string;
|
|
799
|
+
git_clone_workspace?: boolean; approve?: boolean; allow_nested?: boolean;
|
|
800
|
+
};
|
|
801
|
+
|
|
802
|
+
/**
|
|
803
|
+
* Shared internal spawn path used by both subagent_spawn and
|
|
804
|
+
* subagent_spawn_batch. Every launched job becomes a normal subagent run
|
|
805
|
+
* with its own run ID, process, log, metadata, callback, result/output/stop
|
|
806
|
+
* behavior, and sandboxing.
|
|
807
|
+
*/
|
|
808
|
+
async function spawnSubagentRun(
|
|
809
|
+
ctx: ExtensionContext,
|
|
810
|
+
p: SpawnParams,
|
|
811
|
+
batchInfo?: { batchId: string; batchName?: string },
|
|
812
|
+
): Promise<{
|
|
813
|
+
id: string;
|
|
814
|
+
meta: RunMeta;
|
|
815
|
+
spawned: SpawnResult;
|
|
816
|
+
runtime: string;
|
|
817
|
+
warn: string;
|
|
818
|
+
sandboxDir?: string;
|
|
819
|
+
}> {
|
|
820
|
+
const cfg = loadConfig();
|
|
821
|
+
const callbackOrigin = callbackOriginFromContext(ctx);
|
|
822
|
+
activeCallbackOrigin = callbackOrigin;
|
|
823
|
+
|
|
824
|
+
// Sandbox is ON by default. sandbox_dir moves the confinement + working
|
|
825
|
+
// dir elsewhere. git_clone_workspace prepares a disposable clone with
|
|
826
|
+
// .git/ inside the writable root for Git-mutating sandboxed subagents.
|
|
827
|
+
const explicitSandbox = p.sandbox === true || typeof p.sandbox_dir === "string" || p.git_clone_workspace === true;
|
|
828
|
+
const sandboxEnabled = p.sandbox !== false; // default on
|
|
829
|
+
|
|
830
|
+
mkdirSync(sessionsDir(), { recursive: true });
|
|
831
|
+
const id = nextRunId();
|
|
832
|
+
mkdirSync(runDir(id), { recursive: true });
|
|
833
|
+
|
|
834
|
+
const workspace = resolveSubagentWorkspace({
|
|
835
|
+
ctxCwd: ctx.cwd,
|
|
836
|
+
cwd: p.cwd,
|
|
837
|
+
sandboxDir: p.sandbox_dir,
|
|
838
|
+
gitCloneWorkspace: p.git_clone_workspace,
|
|
839
|
+
runId: id,
|
|
840
|
+
runDirPath: runDir(id),
|
|
841
|
+
sandboxEnabled,
|
|
842
|
+
});
|
|
843
|
+
const cwd = workspace.cwd;
|
|
844
|
+
const requestedSandboxDir = workspace.requestedSandboxDir;
|
|
845
|
+
const model = p.model ?? cfg.defaultModel ?? (ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined);
|
|
846
|
+
|
|
847
|
+
if (requestedSandboxDir) mkdirSync(requestedSandboxDir, { recursive: true });
|
|
848
|
+
writeFileSync(promptPathFor(id), p.prompt);
|
|
849
|
+
|
|
850
|
+
const clean = p.clean === true;
|
|
851
|
+
|
|
852
|
+
let allow = normalizeTools(
|
|
853
|
+
p.tools ?? cfg.defaultTools ?? (clean ? SAFE_CLEAN_TOOLS : SAFE_DEFAULT_TOOLS),
|
|
854
|
+
);
|
|
855
|
+
if (p.allow_nested) {
|
|
856
|
+
const have = new Set(allow.split(","));
|
|
857
|
+
allow = [...allow.split(","), ...SUBAGENT_TOOLS.filter((t) => !have.has(t))]
|
|
858
|
+
.filter(Boolean).join(",");
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
const resolution = resolveExtensions({
|
|
862
|
+
tools: allow, model, clean, allowNested: p.allow_nested, config: cfg,
|
|
863
|
+
});
|
|
864
|
+
const { args: extArgs, missing } = extensionArgs(resolution, resolveExtensionPath);
|
|
865
|
+
if (missing.length) {
|
|
866
|
+
throw new Error(
|
|
867
|
+
`Subagent needs extension(s) that are not installed: ${missing.join(", ")}. ` +
|
|
868
|
+
`Install them, drop the tools that require them, or remove the mapping from config.json.`,
|
|
869
|
+
);
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
const excludes = new Set<string>();
|
|
873
|
+
if (p.exclude_tools) for (const t of p.exclude_tools.split(",")) if (t.trim()) excludes.add(t.trim());
|
|
874
|
+
if (!p.allow_nested && resolution.mode === "inherit") {
|
|
875
|
+
for (const t of SUBAGENT_TOOLS) excludes.add(t);
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
const args = [
|
|
879
|
+
"-p", "--mode", "json",
|
|
880
|
+
"--session-dir", sessionsDir(),
|
|
881
|
+
"--session-id", id,
|
|
882
|
+
...extArgs,
|
|
883
|
+
...(model ? ["--model", model] : []),
|
|
884
|
+
...(allow ? ["--tools", allow] : []),
|
|
885
|
+
...(excludes.size ? ["--exclude-tools", [...excludes].join(",")] : []),
|
|
886
|
+
...(p.approve ? ["--approve"] : []),
|
|
887
|
+
p.prompt,
|
|
888
|
+
];
|
|
889
|
+
|
|
890
|
+
const piBin = resolvePiBinary();
|
|
891
|
+
const sandboxCommand = requestedSandboxDir
|
|
892
|
+
? maybeBuildSandboxCommand({
|
|
893
|
+
profilePath: join(runDir(id), "sandbox.sb"),
|
|
894
|
+
writableDir: requestedSandboxDir, home: homedir(), piBin, piArgs: args,
|
|
895
|
+
}, { sandboxEnabled, explicitSandbox })
|
|
896
|
+
: undefined;
|
|
897
|
+
const cmd = sandboxCommand ?? { file: piBin, fileArgs: args };
|
|
898
|
+
const sandboxDir = sandboxCommand ? requestedSandboxDir : undefined;
|
|
899
|
+
|
|
900
|
+
const spawned = spawnDetached({ file: cmd.file, fileArgs: cmd.fileArgs, cwd, logPath: logPathFor(id) });
|
|
901
|
+
// Record process identity (pgid, start-time token) so health
|
|
902
|
+
// reconciliation can tell a supervised child from a recycled pid
|
|
903
|
+
// or an orphaned process group (#63). Best-effort: when the OS
|
|
904
|
+
// probes are unavailable the fields stay absent and the run is
|
|
905
|
+
// reconciled via the conservative old-metadata path.
|
|
906
|
+
const identity = captureProcessIdentity(spawned.pid, spawnIdentityProbe);
|
|
907
|
+
|
|
908
|
+
const meta: RunMeta = {
|
|
909
|
+
id, name: p.name, status: "running",
|
|
910
|
+
pid: spawned.pid, spawnPid: process.pid, model, cwd,
|
|
911
|
+
...identity,
|
|
912
|
+
promptPreview: p.prompt.slice(0, 200),
|
|
913
|
+
startedAt: Date.now(), logPath: logPathFor(id), sessionId: id,
|
|
914
|
+
callbackOrigin,
|
|
915
|
+
sandbox: sandboxDir, callback: p.callback !== false,
|
|
916
|
+
...batchInfo,
|
|
917
|
+
};
|
|
918
|
+
writeMeta(meta);
|
|
919
|
+
|
|
920
|
+
void spawned.exit.then((code) => finalizeRun(pi, ctx, id, code));
|
|
921
|
+
|
|
922
|
+
uiCtx = ctx;
|
|
923
|
+
ensureTicker();
|
|
924
|
+
// Start periodic supervision reconciliation (self-stops when idle).
|
|
925
|
+
ensureHealthTicker();
|
|
926
|
+
// Footer hint: a visible run now exists, so `← background work · N` shows.
|
|
927
|
+
updateNavigatorFooter(ctx);
|
|
928
|
+
|
|
929
|
+
const runtime = resolution.mode === "inherit"
|
|
930
|
+
? `Runtime: ALL installed extensions (inheritExtensions) — mid-turn drain risk\n`
|
|
931
|
+
: resolution.specs.length
|
|
932
|
+
? `Runtime: isolated · extensions ${resolution.specs.join(", ")}\n`
|
|
933
|
+
: `Runtime: isolated · built-in tools only\n`;
|
|
934
|
+
const warn = resolution.unmapped.length
|
|
935
|
+
? `NOTE: no extension mapped for ${resolution.unmapped.join(", ")} — ` +
|
|
936
|
+
`${resolution.unmapped.length > 1 ? "these tools" : "this tool"} will NOT exist in the child. ` +
|
|
937
|
+
`Add a toolExtensions entry in config.json.\n`
|
|
938
|
+
: "";
|
|
939
|
+
return { id, meta, spawned, runtime, warn, sandboxDir };
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
// ---- subagent_spawn -------------------------------------------------
|
|
943
|
+
pi.registerTool({
|
|
944
|
+
name: "subagent_spawn",
|
|
945
|
+
label: "Spawn Subagent",
|
|
946
|
+
description:
|
|
947
|
+
"Launch a task in a background pi subagent (a detached `pi -p` process) and return " +
|
|
948
|
+
"IMMEDIATELY with a run id. The foreground session stays free. Completion is reported " +
|
|
949
|
+
"later on the user's next turn — never wait or poll for it.",
|
|
950
|
+
promptSnippet: "Delegate a task to a background subagent that runs without blocking you",
|
|
951
|
+
promptGuidelines: [
|
|
952
|
+
"Use subagent_spawn for independent work the user should not have to wait on. It returns at once with a run id; that return IS the deliverable — report the id to the user and continue.",
|
|
953
|
+
"After subagent_spawn, do NOT call subagent_output or subagent_result in a loop to wait for the result, and do NOT sleep. The run completes on its own and reports back on the next turn.",
|
|
954
|
+
"Only call subagent_result / subagent_output when the user explicitly asks how a run is going or for its result.",
|
|
955
|
+
"The tools param is both the tool allowlist AND what determines which extensions load in the child (e.g. tools='read,bash,web_fetch' loads only the web-tools package). Ask for the tools the task needs and nothing more; clean:true gives a built-ins-only child. Pick a model with the model param (e.g. 'xai/grok-4.5').",
|
|
956
|
+
"By default the subagent is sandboxed (writes confined to its working dir, reads and network open) and triggers completion here on finish. Set callback:false to finish quietly — then read the result on demand via subagent_result.",
|
|
957
|
+
"Use git_clone_workspace:true when the subagent will mutate Git in a sandbox. The parent prepares a disposable, self-contained clone with a real .git/ directory inside the sandbox root, so linked-worktree metadata outside the sandbox cannot stall the child.",
|
|
958
|
+
],
|
|
959
|
+
parameters: Type.Object({
|
|
960
|
+
prompt: Type.String({ description: "The task for the subagent. This is the only context it gets — be self-contained." }),
|
|
961
|
+
name: Type.Optional(Type.String({ description: "Short label for the run (e.g. 'reviewer')." })),
|
|
962
|
+
model: Type.Optional(Type.String({ description: "Model as provider/id (default: inherit foreground model)." })),
|
|
963
|
+
tools: Type.Optional(Type.String({ description: "Tool allowlist: comma-separated names the child may use (e.g. 'read,bash,web_fetch'). This ALSO selects which extensions load — only packages backing a requested tool are loaded. Defaults to the configured safe set." })),
|
|
964
|
+
exclude_tools: Type.Optional(Type.String({ description: "Comma-separated tool denylist, applied on top of the allowlist." })),
|
|
965
|
+
clean: Type.Optional(Type.Boolean({ description: "Run a hermetic child with NO extensions at all (only built-ins: read, bash, edit, write). Default false — the extensions backing the requested tools load, so web_fetch and model auth (e.g. xai) work." })),
|
|
966
|
+
sandbox: Type.Optional(Type.Boolean({ description: "Default TRUE (macOS): kernel-confine the child's file WRITES to its working dir — reads and network stay open, but it cannot write outside, whatever it runs. Set false to allow writes anywhere." })),
|
|
967
|
+
sandbox_dir: Type.Optional(Type.String({ description: "Confine writes to (and run the child in) this directory instead of the working dir. Created if missing." })),
|
|
968
|
+
callback: Type.Optional(Type.Boolean({ description: "Default TRUE: on completion, trigger a turn that calls subagent_result and presents the result. Set false to finish quietly — the result is then read on demand via subagent_result." })),
|
|
969
|
+
cwd: Type.Optional(Type.String({ description: "Working directory (default: current)." })),
|
|
970
|
+
git_clone_workspace: Type.Optional(Type.Boolean({ description: "Prepare a disposable Git clone workspace for sandboxed Git-mutating subagents. The clone has a real .git/ directory inside the sandbox writable root and is self-contained after setup." })),
|
|
971
|
+
approve: Type.Optional(Type.Boolean({ description: "Trust project-local files in the child (default: false; headless runs cannot prompt for trust)." })),
|
|
972
|
+
allow_nested: Type.Optional(Type.Boolean({ description: "Allow the child to spawn its own subagents (default: false). Loads this extension in the child and allowlists its tools." })),
|
|
973
|
+
}),
|
|
974
|
+
|
|
975
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
976
|
+
const p = params as SpawnParams;
|
|
977
|
+
if (p.prompt.trim() === "") throw new Error("prompt is empty.");
|
|
978
|
+
|
|
979
|
+
const cfg = loadConfig();
|
|
980
|
+
const maxConcurrent = cfg.maxConcurrent ?? DEFAULT_MAX_CONCURRENT;
|
|
981
|
+
const countRunning = () =>
|
|
982
|
+
listMetas().filter((m) => ownedByThisParent(m) && effectiveStatus(m) === "running").length;
|
|
983
|
+
// Shared with batch-spawn: reserve before any async work so an interleaved
|
|
984
|
+
// batch cannot oversubscribe after this check and before writeMeta.
|
|
985
|
+
const gate = getSharedCapacityGate(countRunning);
|
|
986
|
+
if (!gate.tryReserve(1, maxConcurrent)) {
|
|
987
|
+
throw new Error(`Max concurrent subagents (${maxConcurrent}) reached. Stop or let some finish first.`);
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
try {
|
|
991
|
+
const { id, spawned, runtime, warn, sandboxDir } = await spawnSubagentRun(ctx, p);
|
|
992
|
+
gate.commit(1);
|
|
993
|
+
return text(
|
|
994
|
+
`Subagent launched: ${p.name ? `${p.name} ` : ""}id=${id} (pid ${spawned.pid}).\n` +
|
|
995
|
+
(p.callback === false
|
|
996
|
+
? `Running in the background; the foreground is free. It will finish quietly — read the result with subagent_result id=${id}.\n`
|
|
997
|
+
: `Running in the background; the foreground is free. Its result will be posted back here when it finishes.\n`) +
|
|
998
|
+
(sandboxDir ? `Sandboxed: writes confined to ${sandboxDir}\n` : "") +
|
|
999
|
+
runtime + warn +
|
|
1000
|
+
`Log: ${logPathFor(id)}`,
|
|
1001
|
+
);
|
|
1002
|
+
} catch (err) {
|
|
1003
|
+
gate.release(1);
|
|
1004
|
+
throw err;
|
|
1005
|
+
}
|
|
1006
|
+
},
|
|
1007
|
+
});
|
|
1008
|
+
|
|
1009
|
+
// ---- subagent_spawn_batch -------------------------------------------
|
|
1010
|
+
pi.registerTool({
|
|
1011
|
+
name: "subagent_spawn_batch",
|
|
1012
|
+
label: "Spawn Subagent Batch",
|
|
1013
|
+
description:
|
|
1014
|
+
"Launch several independent background pi subagents at once. Each job becomes a " +
|
|
1015
|
+
"normal subagent run with its own run id, process, log, and metadata. " +
|
|
1016
|
+
"'shared' options are applied to every job; per-job options override them.",
|
|
1017
|
+
promptSnippet: "Launch a batch of background subagents at once",
|
|
1018
|
+
promptGuidelines: [
|
|
1019
|
+
"Use subagent_spawn_batch when you have several independent tasks to delegate. It returns immediately with a batch id and one run id per launched job.",
|
|
1020
|
+
"Each job is a normal subagent run; use subagent_result / subagent_output / subagent_stop with the individual run ids just like subagent_spawn.",
|
|
1021
|
+
"Do NOT poll for results. Each job reports back on its own when it finishes.",
|
|
1022
|
+
"By default the whole batch is rejected if there is not enough capacity. Set onCapacity to 'launch-available' to launch as many as fit and report the rest as skipped.",
|
|
1023
|
+
],
|
|
1024
|
+
parameters: Type.Object({
|
|
1025
|
+
batchName: Type.Optional(Type.String({ description: "Optional display label for the batch." })),
|
|
1026
|
+
shared: Type.Optional(Type.Object({
|
|
1027
|
+
model: Type.Optional(Type.String({ description: "Model as provider/id (default: inherit foreground model)." })),
|
|
1028
|
+
tools: Type.Optional(Type.String({ description: "Tool allowlist applied to every job." })),
|
|
1029
|
+
exclude_tools: Type.Optional(Type.String({ description: "Comma-separated tool denylist applied to every job." })),
|
|
1030
|
+
sandbox: Type.Optional(Type.Boolean({ description: "Default TRUE: kernel-confine writes to the working dir." })),
|
|
1031
|
+
sandbox_dir: Type.Optional(Type.String({ description: "Writable root for every job." })),
|
|
1032
|
+
callback: Type.Optional(Type.Boolean({ description: "Default TRUE: post result back on completion." })),
|
|
1033
|
+
clean: Type.Optional(Type.Boolean({ description: "Hermetic builtins-only child; no extensions load." })),
|
|
1034
|
+
cwd: Type.Optional(Type.String({ description: "Working directory (default: current)." })),
|
|
1035
|
+
git_clone_workspace: Type.Optional(Type.Boolean({ description: "Prepare a disposable Git clone workspace for each job (same semantics as subagent_spawn)." })),
|
|
1036
|
+
approve: Type.Optional(Type.Boolean({ description: "Trust project-local files in children." })),
|
|
1037
|
+
allow_nested: Type.Optional(Type.Boolean({ description: "Allow children to spawn their own subagents." })),
|
|
1038
|
+
}, { description: "Options applied to every job; per-job values override these." })),
|
|
1039
|
+
jobs: Type.Array(Type.Object({
|
|
1040
|
+
prompt: Type.String({ description: "The task for this job." }),
|
|
1041
|
+
name: Type.Optional(Type.String({ description: "Short label for this job." })),
|
|
1042
|
+
model: Type.Optional(Type.String()),
|
|
1043
|
+
tools: Type.Optional(Type.String()),
|
|
1044
|
+
exclude_tools: Type.Optional(Type.String()),
|
|
1045
|
+
sandbox: Type.Optional(Type.Boolean()),
|
|
1046
|
+
sandbox_dir: Type.Optional(Type.String()),
|
|
1047
|
+
callback: Type.Optional(Type.Boolean()),
|
|
1048
|
+
clean: Type.Optional(Type.Boolean()),
|
|
1049
|
+
cwd: Type.Optional(Type.String()),
|
|
1050
|
+
git_clone_workspace: Type.Optional(Type.Boolean()),
|
|
1051
|
+
approve: Type.Optional(Type.Boolean()),
|
|
1052
|
+
allow_nested: Type.Optional(Type.Boolean()),
|
|
1053
|
+
}, { description: "A single batch job." }), {
|
|
1054
|
+
minItems: 1,
|
|
1055
|
+
description: "One or more jobs to launch. Each must have a prompt.",
|
|
1056
|
+
}),
|
|
1057
|
+
onCapacity: Type.Optional(Type.String({ description: 'Capacity behavior: "reject" (default) or "launch-available".' })),
|
|
1058
|
+
}),
|
|
1059
|
+
|
|
1060
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
1061
|
+
const p = params as {
|
|
1062
|
+
batchName?: string;
|
|
1063
|
+
shared?: Partial<SpawnParams>;
|
|
1064
|
+
jobs: Array<Partial<SpawnParams> & { prompt: string }>;
|
|
1065
|
+
onCapacity?: "reject" | "launch-available";
|
|
1066
|
+
};
|
|
1067
|
+
|
|
1068
|
+
const cfg = loadConfig();
|
|
1069
|
+
const maxConcurrent = cfg.maxConcurrent ?? DEFAULT_MAX_CONCURRENT;
|
|
1070
|
+
const countRunning = () =>
|
|
1071
|
+
listMetas().filter((m) => ownedByThisParent(m) && effectiveStatus(m) === "running").length;
|
|
1072
|
+
const launchAvailable = p.onCapacity === "launch-available";
|
|
1073
|
+
// Shared with single-spawn. Reservations count against maxConcurrent so a
|
|
1074
|
+
// concurrent single spawn cannot take a slot the batch already admitted.
|
|
1075
|
+
const gate = getSharedCapacityGate(countRunning);
|
|
1076
|
+
|
|
1077
|
+
validateBatchPlan({ shared: p.shared, jobs: p.jobs, onCapacity: p.onCapacity, config: cfg });
|
|
1078
|
+
|
|
1079
|
+
// reject mode: whole-batch reservation is all-or-nothing. Holding the slots
|
|
1080
|
+
// until each job commits (or the unused remainder is released) closes the
|
|
1081
|
+
// interleaving oversubscribe class — a stale plan alone is not enough.
|
|
1082
|
+
if (!launchAvailable) {
|
|
1083
|
+
// planBatchLaunches still produces the public error text (incl. pending).
|
|
1084
|
+
planBatchLaunches({
|
|
1085
|
+
jobs: p.jobs,
|
|
1086
|
+
runningCount: countRunning(),
|
|
1087
|
+
pendingCount: gate.pending,
|
|
1088
|
+
maxConcurrent,
|
|
1089
|
+
onCapacity: p.onCapacity,
|
|
1090
|
+
});
|
|
1091
|
+
if (!gate.tryReserve(p.jobs.length, maxConcurrent)) {
|
|
1092
|
+
// Race: capacity changed between plan and reserve.
|
|
1093
|
+
throw new Error(formatCapacityRejectMessage({
|
|
1094
|
+
jobCount: p.jobs.length,
|
|
1095
|
+
runningCount: countRunning(),
|
|
1096
|
+
pendingCount: gate.pending,
|
|
1097
|
+
maxConcurrent,
|
|
1098
|
+
}));
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
const names = assignBatchJobNames(p.jobs);
|
|
1103
|
+
const batchId = nextBatchId();
|
|
1104
|
+
const launched: { name: string; id: string }[] = [];
|
|
1105
|
+
const failed: { name: string; reason: string }[] = [];
|
|
1106
|
+
const skipped: { name: string }[] = [];
|
|
1107
|
+
// How many reject-mode reserved slots are still held (not yet committed/released).
|
|
1108
|
+
let reservedRemaining = launchAvailable ? 0 : p.jobs.length;
|
|
1109
|
+
|
|
1110
|
+
// Walk every job in order. launch-available reserves one slot at a time and
|
|
1111
|
+
// backfills when a job fails before a normal run is launched (slot released).
|
|
1112
|
+
for (let i = 0; i < p.jobs.length; i++) {
|
|
1113
|
+
const job = p.jobs[i];
|
|
1114
|
+
const name = names[i];
|
|
1115
|
+
const merged = mergeJobOptions(p.shared, job);
|
|
1116
|
+
|
|
1117
|
+
if (launchAvailable) {
|
|
1118
|
+
if (!gate.tryReserve(1, maxConcurrent)) {
|
|
1119
|
+
for (let j = i; j < p.jobs.length; j++) {
|
|
1120
|
+
skipped.push({ name: names[j] });
|
|
1121
|
+
}
|
|
1122
|
+
break;
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
try {
|
|
1127
|
+
const { id } = await spawnSubagentRun(ctx, { ...merged, name }, { batchId, batchName: p.batchName });
|
|
1128
|
+
gate.commit(1);
|
|
1129
|
+
if (!launchAvailable) reservedRemaining -= 1;
|
|
1130
|
+
launched.push({ name, id });
|
|
1131
|
+
} catch (err) {
|
|
1132
|
+
gate.release(1);
|
|
1133
|
+
if (!launchAvailable) reservedRemaining -= 1;
|
|
1134
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
1135
|
+
failed.push({ name, reason });
|
|
1136
|
+
if (!launchAvailable) {
|
|
1137
|
+
// reject mode: leave already-launched runs running, release any
|
|
1138
|
+
// still-held later reservations, and report every later job as failed.
|
|
1139
|
+
if (reservedRemaining > 0) {
|
|
1140
|
+
gate.release(reservedRemaining);
|
|
1141
|
+
reservedRemaining = 0;
|
|
1142
|
+
}
|
|
1143
|
+
for (let j = i + 1; j < p.jobs.length; j++) {
|
|
1144
|
+
failed.push({
|
|
1145
|
+
name: names[j],
|
|
1146
|
+
reason: "not launched due to earlier job failure in reject mode",
|
|
1147
|
+
});
|
|
1148
|
+
}
|
|
1149
|
+
return text(formatBatchLaunchResponse({
|
|
1150
|
+
batchId, batchName: p.batchName, launched, skipped, failed,
|
|
1151
|
+
}));
|
|
1152
|
+
}
|
|
1153
|
+
// launch-available: failure did not consume a slot — continue so
|
|
1154
|
+
// later jobs can use remaining capacity (backfill).
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
// Safety: any unused reject-mode reservation must not leak.
|
|
1159
|
+
if (reservedRemaining > 0) {
|
|
1160
|
+
gate.release(reservedRemaining);
|
|
1161
|
+
reservedRemaining = 0;
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
return text(formatBatchLaunchResponse({ batchId, batchName: p.batchName, launched, skipped, failed }));
|
|
1165
|
+
},
|
|
1166
|
+
});
|
|
1167
|
+
|
|
1168
|
+
// ---- model-facing read/stop tools -----------------------------------
|
|
1169
|
+
// The definitions live in tools.ts; registration uses the exact objects
|
|
1170
|
+
// the factories return, so tests invoke the same execute handlers the
|
|
1171
|
+
// model reaches (no drift-prone second copy). Stop's only UI side effect
|
|
1172
|
+
// (widget redraw after a kill) is injected as onStopped.
|
|
1173
|
+
pi.registerTool(subagentListTool(Type));
|
|
1174
|
+
pi.registerTool(subagentOutputTool(Type));
|
|
1175
|
+
pi.registerTool(subagentResultTool(Type));
|
|
1176
|
+
pi.registerTool(subagentStopTool(Type, { onStopped: renderWidget }));
|
|
1177
|
+
|
|
1178
|
+
// ---- live-status lifecycle -----------------------------------------
|
|
1179
|
+
// Capture a UI-bearing context and, if runs from a prior session are still
|
|
1180
|
+
// alive, resume the ticking widget. Deferred out of the factory per pi's
|
|
1181
|
+
// "no background resources at load" rule.
|
|
1182
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
1183
|
+
uiCtx = ctx;
|
|
1184
|
+
activeCallbackOrigin = callbackOriginFromContext(ctx);
|
|
1185
|
+
// Reload / session switch hardening (#48):
|
|
1186
|
+
// - Drop any leftover overlay timers/confirm state from a prior session
|
|
1187
|
+
// (defensive if the host skipped session_shutdown before re-start).
|
|
1188
|
+
// - Reinstall the editor wrapper without stacking (marked factory).
|
|
1189
|
+
// - Clear + republish footer statuses (pi clears extension statuses on
|
|
1190
|
+
// session switch/reload; dirty-check only dedupes within a session).
|
|
1191
|
+
// - Repaint the widget even if its last in-memory lines match; the host
|
|
1192
|
+
// may have dropped extension UI during reload/session replacement.
|
|
1193
|
+
ensureSubagentProvider();
|
|
1194
|
+
disposeBackgroundWorkNavigator(ctx);
|
|
1195
|
+
widgetNavActive = false;
|
|
1196
|
+
widgetNavSelectedId = undefined;
|
|
1197
|
+
if (isNavigatorUiAvailable(ctx)) {
|
|
1198
|
+
try { ctx.ui.setStatus(CLOSE_CONFIRM_STATUS_KEY, undefined); } catch { /* ignore */ }
|
|
1199
|
+
}
|
|
1200
|
+
ensureNavigator(ctx);
|
|
1201
|
+
updateNavigatorFooter(ctx);
|
|
1202
|
+
// Clear the retired legacy widget so the shared navigator is the only
|
|
1203
|
+
// list surface for running/orphaned subagents.
|
|
1204
|
+
renderWidget();
|
|
1205
|
+
// Resume supervision reconciliation + durable health-callback recovery
|
|
1206
|
+
// across /reload while current-parent work still needs the ticker
|
|
1207
|
+
// (running/orphaned, or unmarked lost); it stops itself when idle.
|
|
1208
|
+
if (needsMonitoring(listMetas())) ensureHealthTicker();
|
|
1209
|
+
});
|
|
1210
|
+
|
|
1211
|
+
pi.on("session_before_switch", () => {
|
|
1212
|
+
activeCallbackOrigin = undefined;
|
|
1213
|
+
disposeBackgroundWorkNavigator();
|
|
1214
|
+
});
|
|
1215
|
+
|
|
1216
|
+
// Tear down the timer and clear the widget when the session ends.
|
|
1217
|
+
pi.on("session_shutdown", async (_event, ctx) => {
|
|
1218
|
+
activeCallbackOrigin = undefined;
|
|
1219
|
+
stopTicker();
|
|
1220
|
+
stopHealthTicker();
|
|
1221
|
+
spendCache.clear();
|
|
1222
|
+
// Always dispose navigator detail timers (no UI call — just clearInterval).
|
|
1223
|
+
// Safe in every mode; the dispose hook is only set when a TUI overlay opened.
|
|
1224
|
+
disposeBackgroundWorkNavigator(ctx);
|
|
1225
|
+
// Widget clear is intentional in every mode that exposes ui (incl. RPC
|
|
1226
|
+
// — pi docs: setWidget works in both TUI and RPC). Navigator cleanup
|
|
1227
|
+
// is TUI-only: the footer hint is never published outside TUI, so
|
|
1228
|
+
// clearing it in RPC would be a pure UI leak (setStatus subagents-nav).
|
|
1229
|
+
try { ctx.ui.setWidget("subagents", WIDGET_CLEAR); } catch { /* ignore */ }
|
|
1230
|
+
if (isNavigatorUiAvailable(ctx)) {
|
|
1231
|
+
try { ctx.ui.setStatus(NAVIGATOR_STATUS_KEY, undefined); } catch { /* ignore */ }
|
|
1232
|
+
try { ctx.ui.setStatus(CLOSE_CONFIRM_STATUS_KEY, undefined); } catch { /* ignore */ }
|
|
1233
|
+
}
|
|
1234
|
+
});
|
|
1235
|
+
}
|