pi-legion 2.0.3 → 3.0.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/CHANGELOG.md +93 -1
- package/CONTRIBUTING.md +27 -24
- package/README.md +103 -19
- package/docs/contracts/osc-busy-plugin.md +72 -0
- package/docs/contracts/spawn-default-inprocess-v3.md +322 -0
- package/docs/contracts/wait-not-stop.md +30 -0
- package/docs/design.md +2 -2
- package/docs/headless-child-contract.md +83 -0
- package/docs/inprocess-supervisor.md +140 -0
- package/docs/qa-hunting-round.md +150 -0
- package/docs/subagents-teams.md +55 -18
- package/docs/tui-qa-playbook.md +284 -0
- package/index.ts +396 -62
- package/package.json +15 -8
- package/skills/pi-legion/SKILL.md +5 -0
- package/src/agents/agents.ts +34 -6
- package/src/agents/context.ts +83 -1
- package/src/agents/overrides.ts +1 -0
- package/src/agents/settings.ts +122 -1
- package/src/api.ts +23 -0
- package/src/extension/child-guard.ts +42 -3
- package/src/extension/legion-commands.ts +15 -3
- package/src/extension/legion-doctor.ts +55 -0
- package/src/extension/legion-stop.ts +6 -2
- package/src/extension/playbook.ts +3 -3
- package/src/extension/runtime.ts +57 -8
- package/src/extension/scout-guard.ts +39 -1
- package/src/extension/session-errors.ts +191 -0
- package/src/extension/timing.ts +391 -0
- package/src/runs/args.ts +8 -0
- package/src/runs/orchestrator.ts +125 -16
- package/src/shared/output-cap.ts +2 -2
- package/src/shared/session.ts +2 -1
- package/src/shared/timing-breakdown.ts +23 -0
- package/src/shared/types.ts +74 -0
- package/src/supervisor/child-context.ts +90 -0
- package/src/supervisor/child-memory.ts +27 -3
- package/src/supervisor/inprocess-depth.ts +71 -0
- package/src/supervisor/inprocess-supervisor.ts +1011 -0
- package/src/supervisor/routing-supervisor.ts +179 -0
- package/src/supervisor/rpc-supervisor.ts +14 -0
- package/src/supervisor/spawn-mode.ts +35 -0
- package/src/supervisor/types.ts +13 -0
- package/src/supervisor/ui-proxy.ts +78 -0
- package/src/tui/live-overlay.ts +297 -24
- package/src/tui/status.ts +71 -35
- package/src/tui/viz/layout.ts +50 -6
- package/src/tui/viz/model.ts +6 -0
- package/src/tui/viz/panels.ts +164 -12
- package/src/tui/viz/railway.ts +154 -21
- package/src/tui/viz/rate.ts +36 -14
- package/src/tui/viz/view.ts +69 -10
package/index.ts
CHANGED
|
@@ -20,6 +20,8 @@ import type {
|
|
|
20
20
|
} from "@earendil-works/pi-coding-agent";
|
|
21
21
|
import { Type, type Static } from "typebox";
|
|
22
22
|
import { RpcSupervisor } from "./src/supervisor/rpc-supervisor.ts";
|
|
23
|
+
import { InProcessSupervisor } from "./src/supervisor/inprocess-supervisor.ts";
|
|
24
|
+
import { RoutingSupervisor } from "./src/supervisor/routing-supervisor.ts";
|
|
23
25
|
import type { LegionSupervisor } from "./src/supervisor/types.ts";
|
|
24
26
|
import { InMemoryUIProxy, type UIProxy } from "./src/supervisor/ui-proxy.ts";
|
|
25
27
|
import { discoverAgents, findAgent, formatAgentRoster, BUILTIN_AGENTS_DIR } from "./src/agents/agents.ts";
|
|
@@ -42,6 +44,7 @@ import {
|
|
|
42
44
|
loadSubagentSettings,
|
|
43
45
|
resolveSubagentSettings,
|
|
44
46
|
} from "./src/agents/settings.ts";
|
|
47
|
+
import { resolveTeamContext } from "./src/agents/context.ts";
|
|
45
48
|
import { createSessionRuntime, renderLiveChildStatus, type SessionRuntime, shouldRecycleAfterCollect, type WaitResult } from "./src/extension/runtime.ts";
|
|
46
49
|
import { createDbEventSource, createLegionEventBus, type DbEventSource, type LegionEventBus } from "./src/extension/event-bus.ts";
|
|
47
50
|
import { LegionPostman, registerLegionTools, type LegionMailCommand } from "./src/extension/legion-tools.ts";
|
|
@@ -56,6 +59,7 @@ import { parseNestedPathEnv } from "./src/shared/nested-path.ts";
|
|
|
56
59
|
import { allocateSiblingNames, isValidAgentName, makeName } from "./src/shared/name.ts";
|
|
57
60
|
import { deriveOutcome, isLastTurnComplete, parseSessionFile } from "./src/shared/session.ts";
|
|
58
61
|
import { restoreHostNodeOptions } from "./src/supervisor/child-memory.ts";
|
|
62
|
+
import { currentChildContext } from "./src/supervisor/child-context.ts";
|
|
59
63
|
import { setVerifyQueueListener } from "./src/runs/verify-queue.ts";
|
|
60
64
|
import { applyEventRetention, checkpoint, formatHygieneReport, hygieneEnabled, runStartupHygiene } from "./src/runs/hygiene.ts";
|
|
61
65
|
import {
|
|
@@ -80,6 +84,7 @@ import {
|
|
|
80
84
|
} from "./src/extension/notify.ts";
|
|
81
85
|
import { renderSubagentNotice } from "./src/extension/notice-renderer.ts";
|
|
82
86
|
import { PARENT_PLAYBOOK, TOOL_DESCRIPTION } from "./src/extension/playbook.ts";
|
|
87
|
+
import { configureSessionErrors, recordSessionError } from "./src/extension/session-errors.ts";
|
|
83
88
|
import { getAgentDir } from "./src/agents/paths.ts";
|
|
84
89
|
import { expectedModelsFromSettings, parseDoctorSections, runLegionDoctor } from "./src/extension/legion-doctor.ts";
|
|
85
90
|
import { readFileSync } from "node:fs";
|
|
@@ -99,6 +104,7 @@ import {
|
|
|
99
104
|
type ModelOrigin,
|
|
100
105
|
type RetireOptions,
|
|
101
106
|
SubagentError,
|
|
107
|
+
type SubagentsSettings,
|
|
102
108
|
} from "./src/shared/types.ts";
|
|
103
109
|
|
|
104
110
|
// ---------------------------------------------------------------------------
|
|
@@ -143,7 +149,7 @@ const SubagentParams = Type.Object({
|
|
|
143
149
|
Type.Literal("stop"),
|
|
144
150
|
Type.Literal("diagnose"),
|
|
145
151
|
],
|
|
146
|
-
{ description: "Defaults to launch. diagnose = Legion doctor self-check (extensions/models/spawn/memory/tools/storage/runtime); optional section filters like /legion doctor." },
|
|
152
|
+
{ description: "Defaults to launch. wait REQUIRES name or all:true (mutually exclusive) — bare wait is rejected. diagnose = Legion doctor self-check (extensions/models/spawn/memory/tools/storage/runtime); optional section filters like /legion doctor." },
|
|
147
153
|
),
|
|
148
154
|
),
|
|
149
155
|
agent: Type.Optional(
|
|
@@ -188,12 +194,12 @@ const SubagentParams = Type.Object({
|
|
|
188
194
|
name: Type.Optional(
|
|
189
195
|
Type.String({
|
|
190
196
|
description:
|
|
191
|
-
"Child handle name. On launch (single agent+task): registers this exact name (lowercase [a-z][a-z0-9_-]{0,31}) instead of auto scout-N; collisions refuse. On control actions (
|
|
197
|
+
"Child handle name. On launch (single agent+task): registers this exact name (lowercase [a-z][a-z0-9_-]{0,31}) instead of auto scout-N; collisions refuse. On wait: REQUIRED unless all:true (mutually exclusive with all). On other control actions (stop/collect/…): addresses that child. For status: omit (or pass all:true) for a live-children overview; pass name for one child.",
|
|
192
198
|
}),
|
|
193
199
|
),
|
|
194
200
|
all: Type.Optional(
|
|
195
201
|
Type.Boolean({
|
|
196
|
-
description: "wait: wait for
|
|
202
|
+
description: "wait: REQUIRED unless name is set — wait for every live (working/blocked) child; mutually exclusive with name. stop: stop every non-retired child; status: overview of all live (non-retired) children under cwd",
|
|
197
203
|
}),
|
|
198
204
|
),
|
|
199
205
|
timeoutMs: Type.Optional(
|
|
@@ -201,6 +207,12 @@ const SubagentParams = Type.Object({
|
|
|
201
207
|
description: "wait: per-child timeout in ms; event-driven completion returns early (default: role turnTimeoutMs)",
|
|
202
208
|
}),
|
|
203
209
|
),
|
|
210
|
+
force: Type.Optional(
|
|
211
|
+
Type.Boolean({
|
|
212
|
+
description:
|
|
213
|
+
'stop only: when true, hard-retire via SIGTERM→SIGKILL + descendant sweep (retire hardening). Omit/false = graceful retire. Example: subagent({ action: "stop", name: "worker-0", force: true }).',
|
|
214
|
+
}),
|
|
215
|
+
),
|
|
204
216
|
message: Type.Optional(
|
|
205
217
|
Type.String({ description: "Text for steer/continue" }),
|
|
206
218
|
),
|
|
@@ -210,7 +222,7 @@ const SubagentParams = Type.Object({
|
|
|
210
222
|
section: Type.Optional(
|
|
211
223
|
Type.String({
|
|
212
224
|
description:
|
|
213
|
-
'diagnose only: optional section filter (summary|extensions|models|spawn|memory|tools|storage|runtime|next), same as `/legion doctor [section]`. Space/comma-separated for multiple.',
|
|
225
|
+
'diagnose only: optional section filter (summary|extensions|models|spawn|memory|tools|storage|runtime|errors|next), same as `/legion doctor [section]`. Space/comma-separated for multiple.',
|
|
214
226
|
}),
|
|
215
227
|
),
|
|
216
228
|
});
|
|
@@ -272,8 +284,8 @@ function settingsForLegion(settings: ReturnType<typeof loadSubagentSettings>): L
|
|
|
272
284
|
return { ...DEFAULT_LEGION_SETTINGS, ...(settings.legion ?? {}), phaseTimeouts: { ...DEFAULT_LEGION_SETTINGS.phaseTimeouts, ...(settings.legion?.phaseTimeouts ?? {}) } };
|
|
273
285
|
}
|
|
274
286
|
|
|
275
|
-
function runStoreFor(cwd: string): RunStore {
|
|
276
|
-
const sharedDbPath = process.env[LEGION_DB_ENV];
|
|
287
|
+
function runStoreFor(cwd: string, dbPathOverride?: string): RunStore {
|
|
288
|
+
const sharedDbPath = dbPathOverride ?? process.env[LEGION_DB_ENV];
|
|
277
289
|
if (sharedDbPath && path.isAbsolute(sharedDbPath)) {
|
|
278
290
|
const storeRoot = path.dirname(path.dirname(path.dirname(sharedDbPath)));
|
|
279
291
|
return new RunStore({ rootDir: storeRoot });
|
|
@@ -350,9 +362,17 @@ function resolveLegionIdentity(input: {
|
|
|
350
362
|
cached?: LegionRuntimeIdentity;
|
|
351
363
|
/** This process's session file (identity credential). From ctx.sessionManager. */
|
|
352
364
|
sessionFile?: string | undefined;
|
|
365
|
+
/**
|
|
366
|
+
* In-process child identity (#197 D8): the orchestrator names the ledger row
|
|
367
|
+
* this session owns. Wins over `process.env.PI_LEGION_DB/NODE_ID` because an
|
|
368
|
+
* in-process child shares the parent's env and would otherwise read the
|
|
369
|
+
* *parent's* identity — or nothing at all when the parent is root.
|
|
370
|
+
*/
|
|
371
|
+
legionNode?: { dbPath: string; nodeId: string } | undefined;
|
|
353
372
|
}): LegionRuntimeIdentity | undefined {
|
|
354
|
-
const
|
|
355
|
-
const
|
|
373
|
+
const injected = input.legionNode;
|
|
374
|
+
const dbPath = injected?.dbPath ?? process.env[LEGION_DB_ENV];
|
|
375
|
+
const nodeId = injected?.nodeId ?? process.env[LEGION_NODE_ID_ENV];
|
|
356
376
|
if (Boolean(dbPath) !== Boolean(nodeId)) throw new Error("PI_LEGION_DB and PI_LEGION_NODE_ID must be set together");
|
|
357
377
|
if (dbPath && nodeId) {
|
|
358
378
|
if (!path.isAbsolute(dbPath)) throw new Error("PI_LEGION_DB must be an absolute path");
|
|
@@ -469,15 +489,26 @@ async function resumeLegionChild(input: { pi: ExtensionAPI; supervisor: LegionSu
|
|
|
469
489
|
const childRun = store.readRun(input.node.runId);
|
|
470
490
|
if (!childRun) throw new Error(`child run.json not found: ${input.node.runId}`);
|
|
471
491
|
const parentOfSupervisor = parent.parentId ? getNode(input.db, parent.parentId) : null;
|
|
472
|
-
const orchestrator = new Orchestrator({ supervisor: input.supervisor, runDir: store.runDir(parentRun.runId), cwd, maxDepth: catalog.settings.legion?.maxDepth, maxSpawns: catalog.settings.maxSubagentSpawnsPerSession ?? null, team: resumedTeam.team, childContext: catalog.settings.
|
|
492
|
+
const orchestrator = new Orchestrator({ supervisor: input.supervisor, runDir: store.runDir(parentRun.runId), cwd, maxDepth: catalog.settings.legion?.maxDepth, maxSpawns: catalog.settings.maxSubagentSpawnsPerSession ?? null, team: resumedTeam.team, childContext: resolveTeamContext("child", catalog.settings, resumedTeam.team), parentPath: parentRun.path, ...rpcApproveFrom(catalog.settings), legion: { db: input.db, dbPath: input.dbPath, nodeId: parent.id, parentNodeId: parent.parentId, parentRunId: parentOfSupervisor?.runId ?? null, runId: parentRun.runId, runStore: store, settings: input.settings, eventBus: input.eventBus } });
|
|
473
493
|
orchestrator.restore(parentRun);
|
|
474
494
|
const handle = await orchestrator.launch({ agent, task: input.message, name: saved.name, ...(input.node.team ? { team: input.node.team } : {}), ...(saved.model ? { model: saved.model } : {}), worktree: Boolean(saved.worktreePath) });
|
|
475
495
|
await store.updateChild(parentRun.runId, saved.name, (child) => Object.assign(child, handle.child));
|
|
476
496
|
}
|
|
477
497
|
|
|
478
498
|
export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps = {}) {
|
|
479
|
-
|
|
480
|
-
|
|
499
|
+
// #197 D8: a session is a child if either transport says so. RPC children
|
|
500
|
+
// are separate processes marked in env; in-process children share the
|
|
501
|
+
// parent's `process.env`, so their marker can only come from the
|
|
502
|
+
// AsyncLocalStorage child context the orchestrator injects.
|
|
503
|
+
//
|
|
504
|
+
// Security note: the ALS context does not survive `spawn()`, so a real
|
|
505
|
+
// subprocess (`bash npm test`, a nested CLI, …) cannot inherit this
|
|
506
|
+
// identity. That is exactly the isolation the env channel lacked — see the
|
|
507
|
+
// "healthy ghost nodes" incident documented at the identity credential
|
|
508
|
+
// check in `resolveLegionIdentity`. Do not replace this with env injection.
|
|
509
|
+
const childContext = currentChildContext();
|
|
510
|
+
const isChild = process.env.PI_SUBAGENT_CHILD === "1" || childContext !== undefined;
|
|
511
|
+
const allowNested = process.env[ALLOW_NESTED_ENV] === "1" || childContext?.allowNested === true;
|
|
481
512
|
if (isChild) restoreHostNodeOptions();
|
|
482
513
|
else {
|
|
483
514
|
// Root UI: surface verify-queue waits instead of a silent stall.
|
|
@@ -488,10 +519,28 @@ export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps
|
|
|
488
519
|
} catch { /* no UI */ }
|
|
489
520
|
});
|
|
490
521
|
}
|
|
491
|
-
const
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
522
|
+
const onSpawnRefused = (message: string) => {
|
|
523
|
+
try { lastUi?.notify(`pi-legion: ${message}`, "error"); } catch { /* no UI (child / print mode) */ }
|
|
524
|
+
};
|
|
525
|
+
// Mutable host cwd so inprocess limits can re-read project settings as the
|
|
526
|
+
// extension follows the parent session's working directory (#197).
|
|
527
|
+
let lastCwd = process.cwd();
|
|
528
|
+
const supervisor = deps.supervisor ?? new RoutingSupervisor({
|
|
529
|
+
rpc: new RpcSupervisor({ onSpawnRefused }),
|
|
530
|
+
inprocess: new InProcessSupervisor({
|
|
531
|
+
onSpawnRefused,
|
|
532
|
+
getLimits: () => {
|
|
533
|
+
try {
|
|
534
|
+
return loadSubagentSettings({
|
|
535
|
+
userSettingsPath: path.join(getAgentDir(), "settings.json"),
|
|
536
|
+
projectSettingsPath: path.join(lastCwd, ".pi", "settings.json"),
|
|
537
|
+
}).inprocess;
|
|
538
|
+
} catch {
|
|
539
|
+
return undefined;
|
|
540
|
+
}
|
|
541
|
+
},
|
|
542
|
+
}),
|
|
543
|
+
onSpawnRefused,
|
|
495
544
|
});
|
|
496
545
|
const verifyRunner = deps.verifyRunner;
|
|
497
546
|
const legionBus = createLegionEventBus();
|
|
@@ -516,7 +565,6 @@ export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps
|
|
|
516
565
|
return true;
|
|
517
566
|
}
|
|
518
567
|
};
|
|
519
|
-
let lastCwd = process.cwd();
|
|
520
568
|
let legionIdentity: LegionRuntimeIdentity | undefined;
|
|
521
569
|
const stopLegionRuntime = (): void => {
|
|
522
570
|
if (!legionIdentity) return;
|
|
@@ -528,13 +576,15 @@ export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps
|
|
|
528
576
|
legionIdentity.db.close();
|
|
529
577
|
legionIdentity = undefined;
|
|
530
578
|
};
|
|
531
|
-
const initializeLegionRuntime = (ctx: Pick<ExtensionContext, "cwd"> & { sessionFile?: string }): void => {
|
|
532
|
-
|
|
579
|
+
const initializeLegionRuntime = (ctx: Pick<ExtensionContext, "cwd"> & { sessionFile?: string; legionNode?: { dbPath: string; nodeId: string } }): void => {
|
|
580
|
+
// The injected node's dbPath is authoritative: an in-process child shares the
|
|
581
|
+
// parent's env, so PI_LEGION_DB here belongs to the parent (or is unset).
|
|
582
|
+
const store = runStoreFor(ctx.cwd, ctx.legionNode?.dbPath);
|
|
533
583
|
const settings = loadSubagentSettings({
|
|
534
584
|
userSettingsPath: path.join(getAgentDir(), "settings.json"),
|
|
535
585
|
projectSettingsPath: path.join(ctx.cwd, ".pi", "settings.json"),
|
|
536
586
|
});
|
|
537
|
-
const next = resolveLegionIdentity({ pi, supervisor, store, cwd: ctx.cwd, settings, eventBus: legionBus, cached: legionIdentity, sessionFile: ctx.sessionFile });
|
|
587
|
+
const next = resolveLegionIdentity({ pi, supervisor, store, cwd: ctx.cwd, settings, eventBus: legionBus, cached: legionIdentity, sessionFile: ctx.sessionFile, legionNode: ctx.legionNode });
|
|
538
588
|
if (legionIdentity && next !== legionIdentity) stopLegionRuntime();
|
|
539
589
|
legionIdentity = next;
|
|
540
590
|
};
|
|
@@ -546,30 +596,80 @@ export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps
|
|
|
546
596
|
try { initializeLegionRuntime({ cwd: process.cwd() }); }
|
|
547
597
|
catch (error) { console.error(`[pi-legion] legion initialization failed: ${error instanceof Error ? error.message : String(error)}`); }
|
|
548
598
|
}
|
|
599
|
+
// #197 D8: an in-process child never sees `session_start` — the SDK emits it
|
|
600
|
+
// from `bindExtensions`, which only rpc / print / interactive modes call;
|
|
601
|
+
// `createAgentSession` does not. So the handler further down is dead for
|
|
602
|
+
// this transport and the injected identity is attached here instead.
|
|
603
|
+
//
|
|
604
|
+
// It is attached on the first ctx-bearing event rather than at extension
|
|
605
|
+
// load: the identity credential (this session's transcript file) only
|
|
606
|
+
// exists on `ctx.sessionManager`, and skipping it would defeat the
|
|
607
|
+
// ghost-node guard inside `resolveLegionIdentity`.
|
|
608
|
+
if (childContext?.nodeId && childContext.dbPath) {
|
|
609
|
+
const legionNode = { dbPath: childContext.dbPath, nodeId: childContext.nodeId };
|
|
610
|
+
let attached = false;
|
|
611
|
+
const attachInjectedChildIdentity = (_event: unknown, ctx?: { cwd?: string; sessionManager?: { getSessionFile?: () => string | undefined } }): void => {
|
|
612
|
+
if (attached) return;
|
|
613
|
+
attached = true;
|
|
614
|
+
try {
|
|
615
|
+
initializeLegionRuntime({
|
|
616
|
+
cwd: ctx?.cwd ?? process.cwd(),
|
|
617
|
+
legionNode,
|
|
618
|
+
sessionFile: ctx?.sessionManager?.getSessionFile?.(),
|
|
619
|
+
});
|
|
620
|
+
} catch (error) {
|
|
621
|
+
console.error(`[pi-legion] legion initialization failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
622
|
+
}
|
|
623
|
+
};
|
|
624
|
+
pi.on("before_agent_start", attachInjectedChildIdentity);
|
|
625
|
+
}
|
|
549
626
|
}
|
|
627
|
+
// #37/#38/#40/#181: one best-effort storage sweep per root process.
|
|
628
|
+
// Deferred 1.5s so interactive first paint is not blocked; unref so a long
|
|
629
|
+
// idle session can exit. Short --print sessions exit before the timer —
|
|
630
|
+
// flushHygieneIfPending on session_shutdown still runs the sweep once.
|
|
550
631
|
let hygieneScheduled = false;
|
|
632
|
+
let hygieneCompleted = false;
|
|
633
|
+
let pendingHygiene: { storeRoot: string; timer?: ReturnType<typeof setTimeout>; notify?: (text: string) => void } | undefined;
|
|
634
|
+
const flushHygieneIfPending = (): void => {
|
|
635
|
+
if (hygieneCompleted || !pendingHygiene || !hygieneEnabled()) return;
|
|
636
|
+
hygieneCompleted = true;
|
|
637
|
+
if (pendingHygiene.timer) {
|
|
638
|
+
clearTimeout(pendingHygiene.timer);
|
|
639
|
+
pendingHygiene.timer = undefined;
|
|
640
|
+
}
|
|
641
|
+
const { storeRoot, notify } = pendingHygiene;
|
|
642
|
+
try {
|
|
643
|
+
const report = runStartupHygiene({ storeRoot, protectRunIds: legionIdentity ? [legionIdentity.rootRunId] : [] });
|
|
644
|
+
const text = formatHygieneReport(report);
|
|
645
|
+
if (text) notify?.(text);
|
|
646
|
+
} catch (error) {
|
|
647
|
+
console.error(`[pi-legion] storage sweep failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
648
|
+
}
|
|
649
|
+
};
|
|
551
650
|
pi.on("session_start", (_event, ctx) => {
|
|
552
651
|
try { initializeLegionRuntime({ cwd: ctx.cwd, sessionFile: ctx.sessionManager?.getSessionFile() }); }
|
|
553
652
|
catch (error) { console.error(`[pi-legion] legion initialization failed: ${error instanceof Error ? error.message : String(error)}`); }
|
|
554
|
-
// #37/#38/#40: one deferred, best-effort storage sweep per root process.
|
|
555
653
|
if (!isChild && !hygieneScheduled && hygieneEnabled()) {
|
|
556
654
|
hygieneScheduled = true;
|
|
557
655
|
const storeRoot = runStoreFor(ctx.cwd).rootDir;
|
|
558
656
|
const timer = setTimeout(() => {
|
|
559
|
-
|
|
560
|
-
const report = runStartupHygiene({ storeRoot, protectRunIds: legionIdentity ? [legionIdentity.rootRunId] : [] });
|
|
561
|
-
const text = formatHygieneReport(report);
|
|
562
|
-
if (text) ctx.ui?.notify?.(text, "info");
|
|
563
|
-
} catch (error) {
|
|
564
|
-
console.error(`[pi-legion] storage sweep failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
565
|
-
}
|
|
657
|
+
flushHygieneIfPending();
|
|
566
658
|
}, 1_500);
|
|
567
659
|
timer.unref?.();
|
|
660
|
+
pendingHygiene = {
|
|
661
|
+
storeRoot,
|
|
662
|
+
timer,
|
|
663
|
+
notify: (text) => ctx.ui?.notify?.(text, "info"),
|
|
664
|
+
};
|
|
568
665
|
}
|
|
569
666
|
});
|
|
570
667
|
const pendingUtilityRetires: Array<(opts?: RetireOptions) => Promise<void>> = [];
|
|
571
668
|
let sessionRuntime: SessionRuntime | undefined;
|
|
572
669
|
const shutdownRetireAll = async (_event?: unknown, ctx?: { mode?: string }): Promise<void> => {
|
|
670
|
+
// #181: short --print exits before the deferred timer; flush now so
|
|
671
|
+
// orphan rpc-child-* dirs still get swept on a clean exit.
|
|
672
|
+
if (!isChild) flushHygieneIfPending();
|
|
573
673
|
// pi --print / JSON exits when the parent turn ends. Async children
|
|
574
674
|
// have only just been spawned; retiring them here aborts the run
|
|
575
675
|
// before the first model call. Interactive quit still retires immediately.
|
|
@@ -636,7 +736,11 @@ export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps
|
|
|
636
736
|
if (Array.isArray(tools) && !tools.includes("subagent") && !tools.includes("vexillatio")) return;
|
|
637
737
|
if (ctx?.cwd) lastCwd = ctx.cwd;
|
|
638
738
|
const catalog = loadCatalog(lastCwd, lastCwd, undefined);
|
|
639
|
-
const parentContext =
|
|
739
|
+
const parentContext = resolveTeamContext(
|
|
740
|
+
"parent",
|
|
741
|
+
catalog.settings,
|
|
742
|
+
catalog.team,
|
|
743
|
+
);
|
|
640
744
|
return {
|
|
641
745
|
systemPrompt: `${event.systemPrompt}\n\n${PARENT_PLAYBOOK}\n\n${renderParentRoster(
|
|
642
746
|
catalog.agents,
|
|
@@ -650,6 +754,10 @@ export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps
|
|
|
650
754
|
return;
|
|
651
755
|
}
|
|
652
756
|
let lastUi: ExtensionUIContext | undefined;
|
|
757
|
+
/** Child slugs currently reported as OSC id=legion/<slug>. */
|
|
758
|
+
const childOscIds = new Set<string>();
|
|
759
|
+
/** Extension activity sources forwarded from children via RPC. */
|
|
760
|
+
const childActivitySources = new Set<string>();
|
|
653
761
|
let statusNodeOpener: ((nodeId: string) => void) | undefined;
|
|
654
762
|
let lastConfirm: ((message: string) => Promise<boolean>) | undefined;
|
|
655
763
|
// turn_start/turn_end maintain this so a flush window can wait out a
|
|
@@ -686,9 +794,41 @@ export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps
|
|
|
686
794
|
},
|
|
687
795
|
options,
|
|
688
796
|
) ?? (() => {}),
|
|
689
|
-
emitBusy: (active, label) => {
|
|
797
|
+
emitBusy: (active, label, children = []) => {
|
|
690
798
|
try { pi.events.emit("pi-legion:busy", active ? { active: true, label } : { active: false }); }
|
|
691
799
|
catch { /* optional display integration */ }
|
|
800
|
+
const ui = lastUi as (ExtensionUIContext & {
|
|
801
|
+
setActivity?: (source: string, activity: { message?: string } | undefined) => void;
|
|
802
|
+
setProgramRecord?: (status: { state: "working" | "clear"; id: string; message?: string }) => void;
|
|
803
|
+
}) | undefined;
|
|
804
|
+
const next = new Set(active ? children : []);
|
|
805
|
+
// Drop RPC-fan-in activities for retired children (and everything on full clear).
|
|
806
|
+
if (typeof ui?.setActivity === "function") {
|
|
807
|
+
for (const source of [...childActivitySources]) {
|
|
808
|
+
const slug = source.startsWith("legion/") ? source.slice("legion/".length).split("/")[0] : undefined;
|
|
809
|
+
if (!active || !slug || !next.has(slug)) {
|
|
810
|
+
try { ui.setActivity(source, undefined); } catch { /* ignore */ }
|
|
811
|
+
childActivitySources.delete(source);
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
try {
|
|
815
|
+
ui.setActivity("legion", active ? { message: label } : undefined);
|
|
816
|
+
} catch { /* setActivity is optional until Pi ships it */ }
|
|
817
|
+
}
|
|
818
|
+
if (typeof ui?.setProgramRecord === "function") {
|
|
819
|
+
for (const slug of [...childOscIds]) {
|
|
820
|
+
if (!next.has(slug)) {
|
|
821
|
+
try { ui.setProgramRecord({ id: `legion/${slug}`, state: "clear" }); } catch { /* ignore */ }
|
|
822
|
+
childOscIds.delete(slug);
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
for (const slug of next) {
|
|
826
|
+
try {
|
|
827
|
+
ui.setProgramRecord({ id: `legion/${slug}`, state: "working", message: slug });
|
|
828
|
+
childOscIds.add(slug);
|
|
829
|
+
} catch { /* ignore */ }
|
|
830
|
+
}
|
|
831
|
+
}
|
|
692
832
|
},
|
|
693
833
|
});
|
|
694
834
|
sessionRuntime = runtime;
|
|
@@ -723,6 +863,8 @@ export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps
|
|
|
723
863
|
userSettingsPath: path.join(getAgentDir(), "settings.json"),
|
|
724
864
|
projectSettingsPath: path.join(cwd, ".pi", "settings.json"),
|
|
725
865
|
}),
|
|
866
|
+
// #182: suppress "拆 scout" nag while launches are forbidden.
|
|
867
|
+
subagentsEnabled: (cwd) => subagentsEnabled(cwd),
|
|
726
868
|
});
|
|
727
869
|
}
|
|
728
870
|
// The parent transcript records every finish; the renderer keeps a
|
|
@@ -731,12 +873,17 @@ export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps
|
|
|
731
873
|
|
|
732
874
|
// User stop controls (/legion stop, idle Esc, `x` in /legion): a thin wrapper
|
|
733
875
|
// over the subagent tool's `stop` action — graceful retire of tracked children.
|
|
734
|
-
|
|
876
|
+
// Nova follow-up: `/legion stop --force` can pass `force: true` here (#179).
|
|
877
|
+
const stopChildren = async (params: {
|
|
878
|
+
name?: string;
|
|
879
|
+
all?: boolean;
|
|
880
|
+
force?: boolean;
|
|
881
|
+
}): Promise<{ ok: boolean; text: string }> => {
|
|
735
882
|
const result = await stopAction({
|
|
736
883
|
params,
|
|
737
884
|
runtime,
|
|
738
|
-
retire: async (name) => {
|
|
739
|
-
await runtime.get(name)?.retire?.();
|
|
885
|
+
retire: async (name, opts) => {
|
|
886
|
+
await runtime.get(name)?.retire?.(opts);
|
|
740
887
|
},
|
|
741
888
|
});
|
|
742
889
|
const text = result.content.map((part) => (part.type === "text" ? part.text : "")).join("\n");
|
|
@@ -837,6 +984,7 @@ export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps
|
|
|
837
984
|
}
|
|
838
985
|
const cwd = params.cwd ?? ctx.cwd;
|
|
839
986
|
const store = runStoreFor(cwd);
|
|
987
|
+
configureSessionErrors({ storeRoot: store.rootDir, tool: "subagent", action });
|
|
840
988
|
const catalog = loadCatalog(ctx.cwd, cwd, params.agentScope);
|
|
841
989
|
const { allAgents, settings } = catalog;
|
|
842
990
|
const teamName = action === "launch" ? params.team : undefined;
|
|
@@ -990,6 +1138,36 @@ export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps
|
|
|
990
1138
|
else if (request.method === "setWidget") ui.setWidget(request.widgetKey, request.widgetLines, { placement: request.widgetPlacement });
|
|
991
1139
|
else if (request.method === "setTitle") ui.setTitle(request.title);
|
|
992
1140
|
else if (request.method === "set_editor_text") ui.setEditorText(request.text);
|
|
1141
|
+
else if (request.method === "setActivity") {
|
|
1142
|
+
const uiExt = ui as ExtensionUIContext & {
|
|
1143
|
+
setActivity?: (source: string, activity: { message?: string } | undefined) => void;
|
|
1144
|
+
};
|
|
1145
|
+
if (typeof uiExt.setActivity !== "function") return;
|
|
1146
|
+
const source = `legion/${pending.childName}/${request.source}`;
|
|
1147
|
+
if (request.activity) {
|
|
1148
|
+
childActivitySources.add(source);
|
|
1149
|
+
uiExt.setActivity(source, request.activity);
|
|
1150
|
+
} else {
|
|
1151
|
+
childActivitySources.delete(source);
|
|
1152
|
+
uiExt.setActivity(source, undefined);
|
|
1153
|
+
}
|
|
1154
|
+
} else if (request.method === "setProgramRecord") {
|
|
1155
|
+
const record = request.record;
|
|
1156
|
+
const uiExt = ui as ExtensionUIContext & {
|
|
1157
|
+
setProgramRecord?: (status: {
|
|
1158
|
+
state: "idle" | "working" | "blocked" | "done" | "error" | "clear";
|
|
1159
|
+
id: string;
|
|
1160
|
+
app?: string;
|
|
1161
|
+
kind?: "permission" | "question" | "auth";
|
|
1162
|
+
message?: string;
|
|
1163
|
+
}) => void;
|
|
1164
|
+
};
|
|
1165
|
+
if (typeof uiExt.setProgramRecord !== "function") return;
|
|
1166
|
+
const id = record.id.startsWith("legion/")
|
|
1167
|
+
? record.id
|
|
1168
|
+
: `legion/${pending.childName}/${record.id}`;
|
|
1169
|
+
uiExt.setProgramRecord({ ...record, id });
|
|
1170
|
+
}
|
|
993
1171
|
});
|
|
994
1172
|
|
|
995
1173
|
pi.on("session_start", (event, ctx) => {
|
|
@@ -1118,7 +1296,7 @@ async function controlAction(input: {
|
|
|
1118
1296
|
}
|
|
1119
1297
|
if (!params.name && !params.all) {
|
|
1120
1298
|
return fail(
|
|
1121
|
-
"`name` or `all` is required for wait",
|
|
1299
|
+
"`name` or `all` is required for wait (try all:true or name:\"child-name\")",
|
|
1122
1300
|
ErrorCodes.INVALID_PARAMS,
|
|
1123
1301
|
);
|
|
1124
1302
|
}
|
|
@@ -1134,13 +1312,16 @@ async function controlAction(input: {
|
|
|
1134
1312
|
}
|
|
1135
1313
|
|
|
1136
1314
|
if (action === "stop") {
|
|
1315
|
+
const force = params.force === true;
|
|
1316
|
+
const retireOpts = force ? { graceful: false as const } : undefined;
|
|
1137
1317
|
return stopAction({
|
|
1138
1318
|
params,
|
|
1139
1319
|
runtime: input.runtime,
|
|
1140
|
-
retire: async (name) => {
|
|
1320
|
+
retire: async (name, opts) => {
|
|
1321
|
+
const merged = opts ?? retireOpts;
|
|
1141
1322
|
const job = input.runtime.get(name);
|
|
1142
1323
|
if (job?.retire) {
|
|
1143
|
-
await job.retire();
|
|
1324
|
+
await job.retire(merged);
|
|
1144
1325
|
return;
|
|
1145
1326
|
}
|
|
1146
1327
|
// A completion watch can retire/release a target after stop snapshots
|
|
@@ -1155,18 +1336,52 @@ async function controlAction(input: {
|
|
|
1155
1336
|
runDir: input.store.runDir(found.runId),
|
|
1156
1337
|
cwd: input.cwd,
|
|
1157
1338
|
maxDepth: input.settings.legion?.maxDepth,
|
|
1158
|
-
childContext: input.settings.
|
|
1339
|
+
childContext: resolveTeamContext("child", input.settings, input.team),
|
|
1159
1340
|
...teamForChildren(input.team),
|
|
1341
|
+
...rpcApproveFrom(input.settings),
|
|
1160
1342
|
...(input.legion ? { legion: { ...input.legion, runId: found.runId, runStore: input.store } } : {}),
|
|
1161
1343
|
onBudgetRefused: input.onBudgetRefused,
|
|
1162
1344
|
});
|
|
1163
1345
|
orchestrator.restore(found.run);
|
|
1164
|
-
await orchestrator.retire(name);
|
|
1346
|
+
await orchestrator.retire(name, merged);
|
|
1165
1347
|
await persistChild(input.store, found.runId, orchestrator, name);
|
|
1166
1348
|
},
|
|
1167
1349
|
});
|
|
1168
1350
|
}
|
|
1169
1351
|
|
|
1352
|
+
// status: no name (or all:true) → live-children overview; named → one child.
|
|
1353
|
+
if (action === "status") {
|
|
1354
|
+
if (params.name && params.all) {
|
|
1355
|
+
return fail(
|
|
1356
|
+
"`name` and `all` are mutually exclusive for status.",
|
|
1357
|
+
ErrorCodes.INVALID_PARAMS,
|
|
1358
|
+
);
|
|
1359
|
+
}
|
|
1360
|
+
if (!params.name) {
|
|
1361
|
+
const live = listLiveChildrenForStatus(store.listRuns());
|
|
1362
|
+
return ok(
|
|
1363
|
+
renderStatusOverview(
|
|
1364
|
+
live.map((child) => ({
|
|
1365
|
+
child,
|
|
1366
|
+
alive: input.supervisor.isAlive(child.name),
|
|
1367
|
+
})),
|
|
1368
|
+
),
|
|
1369
|
+
);
|
|
1370
|
+
}
|
|
1371
|
+
const found = findChild(store.listRuns(), params.name);
|
|
1372
|
+
if (!found) return unknownChild(params.name, store, cwd);
|
|
1373
|
+
return ok(renderLiveChildStatus({
|
|
1374
|
+
name: found.child.name,
|
|
1375
|
+
state: found.child.state,
|
|
1376
|
+
agent: found.child.agent,
|
|
1377
|
+
kind: found.child.kind,
|
|
1378
|
+
sessionFile: found.child.sessionFile,
|
|
1379
|
+
execution: found.child.execution,
|
|
1380
|
+
acceptance: found.child.acceptance,
|
|
1381
|
+
alive: input.supervisor.isAlive(params.name),
|
|
1382
|
+
}));
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1170
1385
|
if (!params.name) {
|
|
1171
1386
|
return fail(
|
|
1172
1387
|
`\`name\` is required for the "${action}" action.`,
|
|
@@ -1183,11 +1398,12 @@ async function controlAction(input: {
|
|
|
1183
1398
|
runDir: store.runDir(found.runId),
|
|
1184
1399
|
cwd,
|
|
1185
1400
|
maxDepth: input.settings.legion?.maxDepth,
|
|
1186
|
-
childContext: input.settings.
|
|
1401
|
+
childContext: resolveTeamContext("child", input.settings, input.team),
|
|
1187
1402
|
verifyRunner: input.verifyRunner,
|
|
1188
1403
|
worktreeBootstrap: input.settings.legion?.worktreeBootstrap,
|
|
1189
1404
|
worktreeBootstrapTimeoutMs: input.settings.legion?.worktreeBootstrapTimeoutMs,
|
|
1190
1405
|
...teamForChildren(input.team),
|
|
1406
|
+
...rpcApproveFrom(input.settings),
|
|
1191
1407
|
...(input.legion ? { legion: { ...input.legion, runId: found.runId, runStore: store } } : {}),
|
|
1192
1408
|
onBudgetRefused: input.onBudgetRefused,
|
|
1193
1409
|
});
|
|
@@ -1200,18 +1416,6 @@ async function controlAction(input: {
|
|
|
1200
1416
|
orchestrator,
|
|
1201
1417
|
};
|
|
1202
1418
|
|
|
1203
|
-
if (action === "status") {
|
|
1204
|
-
return ok(renderLiveChildStatus({
|
|
1205
|
-
name: found.child.name,
|
|
1206
|
-
state: found.child.state,
|
|
1207
|
-
agent: found.child.agent,
|
|
1208
|
-
kind: found.child.kind,
|
|
1209
|
-
sessionFile: found.child.sessionFile,
|
|
1210
|
-
execution: found.child.execution,
|
|
1211
|
-
acceptance: found.child.acceptance,
|
|
1212
|
-
alive: input.supervisor.isAlive(params.name),
|
|
1213
|
-
}));
|
|
1214
|
-
}
|
|
1215
1419
|
if (action === "steer") return steerChild(ctx);
|
|
1216
1420
|
if (action === "collect") return collectChild(ctx, input.signal);
|
|
1217
1421
|
if (action === "retire") return retireChild(ctx);
|
|
@@ -1224,7 +1428,7 @@ async function controlAction(input: {
|
|
|
1224
1428
|
interface ChildContext {
|
|
1225
1429
|
action: string;
|
|
1226
1430
|
params: SubagentParams;
|
|
1227
|
-
/** Always set:
|
|
1431
|
+
/** Always set: named control actions reject a request without a name. */
|
|
1228
1432
|
name: string;
|
|
1229
1433
|
supervisor: LegionSupervisor;
|
|
1230
1434
|
uiProxy: UIProxy;
|
|
@@ -1479,11 +1683,12 @@ async function launchFamily(input: {
|
|
|
1479
1683
|
cwd,
|
|
1480
1684
|
maxDepth: settings.legion?.maxDepth,
|
|
1481
1685
|
maxSpawns: settings.maxSubagentSpawnsPerSession ?? null,
|
|
1482
|
-
childContext: settings.
|
|
1686
|
+
childContext: resolveTeamContext("child", settings, input.team),
|
|
1483
1687
|
verifyRunner: input.verifyRunner,
|
|
1484
1688
|
worktreeBootstrap: settings.legion?.worktreeBootstrap,
|
|
1485
1689
|
worktreeBootstrapTimeoutMs: settings.legion?.worktreeBootstrapTimeoutMs,
|
|
1486
1690
|
...teamForChildren(input.team),
|
|
1691
|
+
...rpcApproveFrom(settings),
|
|
1487
1692
|
...(legion ? { legion: { db: legion.db, dbPath: legion.dbPath, nodeId: legion.nodeId, runId: run.runId, parentNodeId: legion.parentNodeId ?? undefined, parentRunId: legion.parentRunId ?? undefined, runStore: store, settings: legion.settings, eventBus: legion.eventBus } } : {}),
|
|
1488
1693
|
onBudgetRefused: input.onBudgetRefused,
|
|
1489
1694
|
});
|
|
@@ -2050,8 +2255,9 @@ async function launchUtilityFamily(input: {
|
|
|
2050
2255
|
cwd,
|
|
2051
2256
|
maxDepth: catalog.settings.legion?.maxDepth,
|
|
2052
2257
|
maxSpawns: catalog.settings.maxSubagentSpawnsPerSession ?? null,
|
|
2053
|
-
childContext: catalog.settings.
|
|
2258
|
+
childContext: resolveTeamContext("child", catalog.settings, catalog.team),
|
|
2054
2259
|
team: catalog.team,
|
|
2260
|
+
...rpcApproveFrom(catalog.settings),
|
|
2055
2261
|
onBudgetRefused: input.onBudgetRefused,
|
|
2056
2262
|
...(legion ? {
|
|
2057
2263
|
legion: {
|
|
@@ -2508,6 +2714,53 @@ async function abandonLaunchedChild(session: LaunchSession, name: string): Promi
|
|
|
2508
2714
|
// wait (§1.5): block for results instead of relying on async notices
|
|
2509
2715
|
// ---------------------------------------------------------------------------
|
|
2510
2716
|
|
|
2717
|
+
/**
|
|
2718
|
+
* Live (non-retired / non-exited) children under this cwd for status overview.
|
|
2719
|
+
*
|
|
2720
|
+
* Names can be reused after retire; prefer the newest live record per name
|
|
2721
|
+
* (same preference as {@link pickChildByName}).
|
|
2722
|
+
*/
|
|
2723
|
+
export function listLiveChildrenForStatus(
|
|
2724
|
+
runs: ReturnType<RunStore["listRuns"]>,
|
|
2725
|
+
): NonNullable<ReturnType<RunStore["findChild"]>>[] {
|
|
2726
|
+
const ranked = [...runs].sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
|
2727
|
+
const seen = new Set<string>();
|
|
2728
|
+
const out: NonNullable<ReturnType<RunStore["findChild"]>>[] = [];
|
|
2729
|
+
for (const run of ranked) {
|
|
2730
|
+
for (const child of run.children) {
|
|
2731
|
+
if (child.state === "retired" || child.state === "exited") continue;
|
|
2732
|
+
if (seen.has(child.name)) continue;
|
|
2733
|
+
seen.add(child.name);
|
|
2734
|
+
out.push(child);
|
|
2735
|
+
}
|
|
2736
|
+
}
|
|
2737
|
+
return out;
|
|
2738
|
+
}
|
|
2739
|
+
|
|
2740
|
+
/** Multi-child status text: one {@link renderLiveChildStatus} block per live child. */
|
|
2741
|
+
export function renderStatusOverview(
|
|
2742
|
+
children: Array<{
|
|
2743
|
+
child: NonNullable<ReturnType<RunStore["findChild"]>>;
|
|
2744
|
+
alive: boolean;
|
|
2745
|
+
}>,
|
|
2746
|
+
): string {
|
|
2747
|
+
if (children.length === 0) return "No live children.";
|
|
2748
|
+
const noun = children.length === 1 ? "child" : "children";
|
|
2749
|
+
const blocks = children.map(({ child, alive }) =>
|
|
2750
|
+
renderLiveChildStatus({
|
|
2751
|
+
name: child.name,
|
|
2752
|
+
state: child.state,
|
|
2753
|
+
agent: child.agent,
|
|
2754
|
+
kind: child.kind,
|
|
2755
|
+
sessionFile: child.sessionFile,
|
|
2756
|
+
execution: child.execution,
|
|
2757
|
+
acceptance: child.acceptance,
|
|
2758
|
+
alive,
|
|
2759
|
+
}),
|
|
2760
|
+
);
|
|
2761
|
+
return [`Status · ${children.length} live ${noun}`, "", blocks.join("\n\n")].join("\n");
|
|
2762
|
+
}
|
|
2763
|
+
|
|
2511
2764
|
/**
|
|
2512
2765
|
* Resolve which children a wait targets.
|
|
2513
2766
|
*
|
|
@@ -2529,7 +2782,7 @@ export function resolveWaitTargets(
|
|
|
2529
2782
|
return { ok: true, names: live.map((job) => job.name) };
|
|
2530
2783
|
}
|
|
2531
2784
|
const name = params.name;
|
|
2532
|
-
if (!name) return { ok: false, message: "`name` or `all` is required for wait" };
|
|
2785
|
+
if (!name) return { ok: false, message: "`name` or `all` is required for wait (try all:true or name:\"child-name\")" };
|
|
2533
2786
|
if (live.some((job) => job.name === name)) {
|
|
2534
2787
|
return { ok: true, names: [name] };
|
|
2535
2788
|
}
|
|
@@ -2539,6 +2792,63 @@ export function resolveWaitTargets(
|
|
|
2539
2792
|
};
|
|
2540
2793
|
}
|
|
2541
2794
|
|
|
2795
|
+
/** Parent-side decision hints when wait leaves children live (#179). */
|
|
2796
|
+
export const WAIT_PARENT_DECISIONS = [
|
|
2797
|
+
"extend",
|
|
2798
|
+
"steer_again",
|
|
2799
|
+
"stop",
|
|
2800
|
+
"force_stop",
|
|
2801
|
+
] as const;
|
|
2802
|
+
|
|
2803
|
+
export type WaitParentDecision = (typeof WAIT_PARENT_DECISIONS)[number];
|
|
2804
|
+
|
|
2805
|
+
export const WAIT_TIMEOUT_HINT =
|
|
2806
|
+
"wait does not stop children; choose stop/force_stop or extend/steer";
|
|
2807
|
+
|
|
2808
|
+
export interface WaitStillRunningChild {
|
|
2809
|
+
name: string;
|
|
2810
|
+
elapsedMs: number;
|
|
2811
|
+
budgetMs?: number;
|
|
2812
|
+
}
|
|
2813
|
+
|
|
2814
|
+
/** Structured wait result when children are still live after wait (#179). */
|
|
2815
|
+
export interface WaitTimeoutDetails {
|
|
2816
|
+
status: "wait_timeout" | "budget_exceeded" | "still_running";
|
|
2817
|
+
done: number;
|
|
2818
|
+
stillRunning: WaitStillRunningChild[];
|
|
2819
|
+
decisions: WaitParentDecision[];
|
|
2820
|
+
hint: string;
|
|
2821
|
+
}
|
|
2822
|
+
|
|
2823
|
+
/** Build parent-decision payload when wait leaves children live. Does not stop them. */
|
|
2824
|
+
export function buildWaitTimeoutDetails(
|
|
2825
|
+
results: WaitResult[],
|
|
2826
|
+
): WaitTimeoutDetails | undefined {
|
|
2827
|
+
const stillRunning: WaitStillRunningChild[] = [];
|
|
2828
|
+
let timedOut = false;
|
|
2829
|
+
let done = 0;
|
|
2830
|
+
for (const result of results) {
|
|
2831
|
+
if (result.snapshot) {
|
|
2832
|
+
done += 1;
|
|
2833
|
+
continue;
|
|
2834
|
+
}
|
|
2835
|
+
if (!result.stillRunning) continue;
|
|
2836
|
+
if (result.timedOut) timedOut = true;
|
|
2837
|
+
stillRunning.push({
|
|
2838
|
+
name: result.name,
|
|
2839
|
+
elapsedMs: result.elapsedMs ?? 0,
|
|
2840
|
+
});
|
|
2841
|
+
}
|
|
2842
|
+
if (stillRunning.length === 0) return undefined;
|
|
2843
|
+
return {
|
|
2844
|
+
status: timedOut ? "wait_timeout" : "still_running",
|
|
2845
|
+
done,
|
|
2846
|
+
stillRunning,
|
|
2847
|
+
decisions: [...WAIT_PARENT_DECISIONS],
|
|
2848
|
+
hint: WAIT_TIMEOUT_HINT,
|
|
2849
|
+
};
|
|
2850
|
+
}
|
|
2851
|
+
|
|
2542
2852
|
/** Render one wait result, reusing the collect shape for finished children. */
|
|
2543
2853
|
export function renderWait(results: WaitResult[]): string {
|
|
2544
2854
|
let done = 0;
|
|
@@ -2551,7 +2861,9 @@ export function renderWait(results: WaitResult[]): string {
|
|
|
2551
2861
|
parts.push(renderCollect(result.name, result.snapshot));
|
|
2552
2862
|
} else if (result.stillRunning) {
|
|
2553
2863
|
running += 1;
|
|
2554
|
-
|
|
2864
|
+
const elapsed =
|
|
2865
|
+
result.elapsedMs !== undefined ? ` (elapsed ${result.elapsedMs}ms)` : "";
|
|
2866
|
+
parts.push(`── ${result.name} ──\nstill running${elapsed}`);
|
|
2555
2867
|
} else if (result.interrupted) {
|
|
2556
2868
|
interrupted += 1;
|
|
2557
2869
|
parts.push(`── ${result.name} ──\ninterrupted (parent turn ended; children still running)`);
|
|
@@ -2561,14 +2873,23 @@ export function renderWait(results: WaitResult[]): string {
|
|
|
2561
2873
|
}
|
|
2562
2874
|
parts.push(`${done} done, ${running} still running`);
|
|
2563
2875
|
if (interrupted > 0) parts.push(`${interrupted} interrupted`);
|
|
2876
|
+
const details = buildWaitTimeoutDetails(results);
|
|
2877
|
+
if (details) {
|
|
2878
|
+
parts.push(`status: ${details.status}`);
|
|
2879
|
+
parts.push(`decisions: ${details.decisions.join(", ")}`);
|
|
2880
|
+
parts.push(`hint: ${details.hint}`);
|
|
2881
|
+
}
|
|
2564
2882
|
return parts.join("\n");
|
|
2565
2883
|
}
|
|
2566
2884
|
|
|
2567
|
-
/**
|
|
2885
|
+
/**
|
|
2886
|
+
* Retire a named child or every child tracked by this session.
|
|
2887
|
+
* `force: true` → hard path (graceful:false / SIGTERM→SIGKILL); omit/false → graceful.
|
|
2888
|
+
*/
|
|
2568
2889
|
export async function stopAction(input: {
|
|
2569
|
-
params: { name?: string; all?: boolean };
|
|
2890
|
+
params: { name?: string; all?: boolean; force?: boolean };
|
|
2570
2891
|
runtime: SessionRuntime;
|
|
2571
|
-
retire: (name: string) => Promise<void>;
|
|
2892
|
+
retire: (name: string, opts?: RetireOptions) => Promise<void>;
|
|
2572
2893
|
}): Promise<AgentToolResult<unknown>> {
|
|
2573
2894
|
const { params, runtime, retire } = input;
|
|
2574
2895
|
if (params.name && params.all) {
|
|
@@ -2577,6 +2898,8 @@ export async function stopAction(input: {
|
|
|
2577
2898
|
if (!params.name && !params.all) {
|
|
2578
2899
|
return fail("`name` or `all` is required for stop", ErrorCodes.INVALID_PARAMS);
|
|
2579
2900
|
}
|
|
2901
|
+
const force = params.force === true;
|
|
2902
|
+
const retireOpts: RetireOptions | undefined = force ? { graceful: false } : undefined;
|
|
2580
2903
|
const jobs = runtime.activeJobs();
|
|
2581
2904
|
const active = jobs.filter((job) => job.state !== "retired");
|
|
2582
2905
|
let targets: string[];
|
|
@@ -2601,18 +2924,19 @@ export async function stopAction(input: {
|
|
|
2601
2924
|
try {
|
|
2602
2925
|
// The runtime job may have been released after the target snapshot was
|
|
2603
2926
|
// taken. The retire adapter can then resolve its persisted run record.
|
|
2604
|
-
await retire(name);
|
|
2927
|
+
await retire(name, retireOpts);
|
|
2605
2928
|
runtime.release(name);
|
|
2606
2929
|
stopped.push(name);
|
|
2607
2930
|
} catch (error) {
|
|
2608
|
-
// If the
|
|
2931
|
+
// If the retire chain fails, restore the completion watcher so a
|
|
2609
2932
|
// still-live child is not orphaned by the explicit stop attempt.
|
|
2610
2933
|
if (runtime.get(name)) runtime.rewatch(name);
|
|
2611
2934
|
failures.push(`${name}: ${String(error)}`);
|
|
2612
2935
|
}
|
|
2613
2936
|
}
|
|
2937
|
+
const mode = force ? "force-stopped" : "Stopped";
|
|
2614
2938
|
const summary = stopped.length
|
|
2615
|
-
?
|
|
2939
|
+
? `${mode} ${stopped.length} child${stopped.length === 1 ? "" : "ren"}: ${stopped.join(", ")}.`
|
|
2616
2940
|
: "No children were stopped.";
|
|
2617
2941
|
return failures.length
|
|
2618
2942
|
? fail(`${summary}\nFailed to stop: ${failures.join("; ")}`, ErrorCodes.RETIRE_FAILED)
|
|
@@ -2664,7 +2988,11 @@ export async function waitAction(input: {
|
|
|
2664
2988
|
}),
|
|
2665
2989
|
);
|
|
2666
2990
|
const results = await runtime.wait(targets.names, { timeoutMs, ...(signal ? { signal } : {}) });
|
|
2667
|
-
|
|
2991
|
+
const text = renderWait(results);
|
|
2992
|
+
const details = buildWaitTimeoutDetails(results);
|
|
2993
|
+
return details
|
|
2994
|
+
? { content: [{ type: "text", text }], details }
|
|
2995
|
+
: ok(text);
|
|
2668
2996
|
}
|
|
2669
2997
|
|
|
2670
2998
|
export function renderAgentList(
|
|
@@ -2721,6 +3049,11 @@ function teamForChildren(team: ActiveTeam): { team?: ActiveTeam } {
|
|
|
2721
3049
|
return team.name !== DEFAULT_TEAM || team.source === "env" ? { team } : {};
|
|
2722
3050
|
}
|
|
2723
3051
|
|
|
3052
|
+
/** Opt-in `--approve` for RPC children (`subagents.rpc.approve`, #193). */
|
|
3053
|
+
function rpcApproveFrom(settings: Pick<SubagentsSettings, "rpc">): { rpcApprove?: boolean } {
|
|
3054
|
+
return settings.rpc?.approve === true ? { rpcApprove: true } : {};
|
|
3055
|
+
}
|
|
3056
|
+
|
|
2724
3057
|
/** Prefer a role under the active team, then fall back for existing children. */
|
|
2725
3058
|
function findControlAgent(
|
|
2726
3059
|
agents: AgentConfig[],
|
|
@@ -2843,5 +3176,6 @@ function ok(text: string): AgentToolResult<unknown> {
|
|
|
2843
3176
|
* `details` and the message text carries the failure signal.
|
|
2844
3177
|
*/
|
|
2845
3178
|
function fail(text: string, code: string): AgentToolResult<unknown> {
|
|
3179
|
+
recordSessionError({ code, message: text });
|
|
2846
3180
|
return { content: [{ type: "text", text }], details: { error: code } };
|
|
2847
3181
|
}
|