pi-legion 2.0.4 → 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 +71 -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 +315 -49
- 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 +9 -2
- package/src/extension/legion-doctor.ts +54 -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/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/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 +147 -55
- package/src/tui/status.ts +71 -35
- package/src/tui/viz/model.ts +6 -0
- package/src/tui/viz/panels.ts +8 -9
- package/src/tui/viz/railway.ts +154 -21
- package/src/tui/viz/rate.ts +15 -14
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,13 +1336,14 @@ 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
|
});
|
|
@@ -1216,11 +1398,12 @@ async function controlAction(input: {
|
|
|
1216
1398
|
runDir: store.runDir(found.runId),
|
|
1217
1399
|
cwd,
|
|
1218
1400
|
maxDepth: input.settings.legion?.maxDepth,
|
|
1219
|
-
childContext: input.settings.
|
|
1401
|
+
childContext: resolveTeamContext("child", input.settings, input.team),
|
|
1220
1402
|
verifyRunner: input.verifyRunner,
|
|
1221
1403
|
worktreeBootstrap: input.settings.legion?.worktreeBootstrap,
|
|
1222
1404
|
worktreeBootstrapTimeoutMs: input.settings.legion?.worktreeBootstrapTimeoutMs,
|
|
1223
1405
|
...teamForChildren(input.team),
|
|
1406
|
+
...rpcApproveFrom(input.settings),
|
|
1224
1407
|
...(input.legion ? { legion: { ...input.legion, runId: found.runId, runStore: store } } : {}),
|
|
1225
1408
|
onBudgetRefused: input.onBudgetRefused,
|
|
1226
1409
|
});
|
|
@@ -1500,11 +1683,12 @@ async function launchFamily(input: {
|
|
|
1500
1683
|
cwd,
|
|
1501
1684
|
maxDepth: settings.legion?.maxDepth,
|
|
1502
1685
|
maxSpawns: settings.maxSubagentSpawnsPerSession ?? null,
|
|
1503
|
-
childContext: settings.
|
|
1686
|
+
childContext: resolveTeamContext("child", settings, input.team),
|
|
1504
1687
|
verifyRunner: input.verifyRunner,
|
|
1505
1688
|
worktreeBootstrap: settings.legion?.worktreeBootstrap,
|
|
1506
1689
|
worktreeBootstrapTimeoutMs: settings.legion?.worktreeBootstrapTimeoutMs,
|
|
1507
1690
|
...teamForChildren(input.team),
|
|
1691
|
+
...rpcApproveFrom(settings),
|
|
1508
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 } } : {}),
|
|
1509
1693
|
onBudgetRefused: input.onBudgetRefused,
|
|
1510
1694
|
});
|
|
@@ -2071,8 +2255,9 @@ async function launchUtilityFamily(input: {
|
|
|
2071
2255
|
cwd,
|
|
2072
2256
|
maxDepth: catalog.settings.legion?.maxDepth,
|
|
2073
2257
|
maxSpawns: catalog.settings.maxSubagentSpawnsPerSession ?? null,
|
|
2074
|
-
childContext: catalog.settings.
|
|
2258
|
+
childContext: resolveTeamContext("child", catalog.settings, catalog.team),
|
|
2075
2259
|
team: catalog.team,
|
|
2260
|
+
...rpcApproveFrom(catalog.settings),
|
|
2076
2261
|
onBudgetRefused: input.onBudgetRefused,
|
|
2077
2262
|
...(legion ? {
|
|
2078
2263
|
legion: {
|
|
@@ -2597,7 +2782,7 @@ export function resolveWaitTargets(
|
|
|
2597
2782
|
return { ok: true, names: live.map((job) => job.name) };
|
|
2598
2783
|
}
|
|
2599
2784
|
const name = params.name;
|
|
2600
|
-
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\")" };
|
|
2601
2786
|
if (live.some((job) => job.name === name)) {
|
|
2602
2787
|
return { ok: true, names: [name] };
|
|
2603
2788
|
}
|
|
@@ -2607,6 +2792,63 @@ export function resolveWaitTargets(
|
|
|
2607
2792
|
};
|
|
2608
2793
|
}
|
|
2609
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
|
+
|
|
2610
2852
|
/** Render one wait result, reusing the collect shape for finished children. */
|
|
2611
2853
|
export function renderWait(results: WaitResult[]): string {
|
|
2612
2854
|
let done = 0;
|
|
@@ -2619,7 +2861,9 @@ export function renderWait(results: WaitResult[]): string {
|
|
|
2619
2861
|
parts.push(renderCollect(result.name, result.snapshot));
|
|
2620
2862
|
} else if (result.stillRunning) {
|
|
2621
2863
|
running += 1;
|
|
2622
|
-
|
|
2864
|
+
const elapsed =
|
|
2865
|
+
result.elapsedMs !== undefined ? ` (elapsed ${result.elapsedMs}ms)` : "";
|
|
2866
|
+
parts.push(`── ${result.name} ──\nstill running${elapsed}`);
|
|
2623
2867
|
} else if (result.interrupted) {
|
|
2624
2868
|
interrupted += 1;
|
|
2625
2869
|
parts.push(`── ${result.name} ──\ninterrupted (parent turn ended; children still running)`);
|
|
@@ -2629,14 +2873,23 @@ export function renderWait(results: WaitResult[]): string {
|
|
|
2629
2873
|
}
|
|
2630
2874
|
parts.push(`${done} done, ${running} still running`);
|
|
2631
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
|
+
}
|
|
2632
2882
|
return parts.join("\n");
|
|
2633
2883
|
}
|
|
2634
2884
|
|
|
2635
|
-
/**
|
|
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
|
+
*/
|
|
2636
2889
|
export async function stopAction(input: {
|
|
2637
|
-
params: { name?: string; all?: boolean };
|
|
2890
|
+
params: { name?: string; all?: boolean; force?: boolean };
|
|
2638
2891
|
runtime: SessionRuntime;
|
|
2639
|
-
retire: (name: string) => Promise<void>;
|
|
2892
|
+
retire: (name: string, opts?: RetireOptions) => Promise<void>;
|
|
2640
2893
|
}): Promise<AgentToolResult<unknown>> {
|
|
2641
2894
|
const { params, runtime, retire } = input;
|
|
2642
2895
|
if (params.name && params.all) {
|
|
@@ -2645,6 +2898,8 @@ export async function stopAction(input: {
|
|
|
2645
2898
|
if (!params.name && !params.all) {
|
|
2646
2899
|
return fail("`name` or `all` is required for stop", ErrorCodes.INVALID_PARAMS);
|
|
2647
2900
|
}
|
|
2901
|
+
const force = params.force === true;
|
|
2902
|
+
const retireOpts: RetireOptions | undefined = force ? { graceful: false } : undefined;
|
|
2648
2903
|
const jobs = runtime.activeJobs();
|
|
2649
2904
|
const active = jobs.filter((job) => job.state !== "retired");
|
|
2650
2905
|
let targets: string[];
|
|
@@ -2669,18 +2924,19 @@ export async function stopAction(input: {
|
|
|
2669
2924
|
try {
|
|
2670
2925
|
// The runtime job may have been released after the target snapshot was
|
|
2671
2926
|
// taken. The retire adapter can then resolve its persisted run record.
|
|
2672
|
-
await retire(name);
|
|
2927
|
+
await retire(name, retireOpts);
|
|
2673
2928
|
runtime.release(name);
|
|
2674
2929
|
stopped.push(name);
|
|
2675
2930
|
} catch (error) {
|
|
2676
|
-
// If the
|
|
2931
|
+
// If the retire chain fails, restore the completion watcher so a
|
|
2677
2932
|
// still-live child is not orphaned by the explicit stop attempt.
|
|
2678
2933
|
if (runtime.get(name)) runtime.rewatch(name);
|
|
2679
2934
|
failures.push(`${name}: ${String(error)}`);
|
|
2680
2935
|
}
|
|
2681
2936
|
}
|
|
2937
|
+
const mode = force ? "force-stopped" : "Stopped";
|
|
2682
2938
|
const summary = stopped.length
|
|
2683
|
-
?
|
|
2939
|
+
? `${mode} ${stopped.length} child${stopped.length === 1 ? "" : "ren"}: ${stopped.join(", ")}.`
|
|
2684
2940
|
: "No children were stopped.";
|
|
2685
2941
|
return failures.length
|
|
2686
2942
|
? fail(`${summary}\nFailed to stop: ${failures.join("; ")}`, ErrorCodes.RETIRE_FAILED)
|
|
@@ -2732,7 +2988,11 @@ export async function waitAction(input: {
|
|
|
2732
2988
|
}),
|
|
2733
2989
|
);
|
|
2734
2990
|
const results = await runtime.wait(targets.names, { timeoutMs, ...(signal ? { signal } : {}) });
|
|
2735
|
-
|
|
2991
|
+
const text = renderWait(results);
|
|
2992
|
+
const details = buildWaitTimeoutDetails(results);
|
|
2993
|
+
return details
|
|
2994
|
+
? { content: [{ type: "text", text }], details }
|
|
2995
|
+
: ok(text);
|
|
2736
2996
|
}
|
|
2737
2997
|
|
|
2738
2998
|
export function renderAgentList(
|
|
@@ -2789,6 +3049,11 @@ function teamForChildren(team: ActiveTeam): { team?: ActiveTeam } {
|
|
|
2789
3049
|
return team.name !== DEFAULT_TEAM || team.source === "env" ? { team } : {};
|
|
2790
3050
|
}
|
|
2791
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
|
+
|
|
2792
3057
|
/** Prefer a role under the active team, then fall back for existing children. */
|
|
2793
3058
|
function findControlAgent(
|
|
2794
3059
|
agents: AgentConfig[],
|
|
@@ -2911,5 +3176,6 @@ function ok(text: string): AgentToolResult<unknown> {
|
|
|
2911
3176
|
* `details` and the message text carries the failure signal.
|
|
2912
3177
|
*/
|
|
2913
3178
|
function fail(text: string, code: string): AgentToolResult<unknown> {
|
|
3179
|
+
recordSessionError({ code, message: text });
|
|
2914
3180
|
return { content: [{ type: "text", text }], details: { error: code } };
|
|
2915
3181
|
}
|