pi-legion 0.16.0 → 2.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/CONTRIBUTING.md +58 -0
- package/README.md +313 -640
- package/agents/advisor.md +7 -5
- package/agents/centurion.md +53 -0
- package/agents/designer.md +33 -33
- package/agents/planner.md +1 -2
- package/agents/prototype.md +2 -2
- package/agents/reviewer.md +67 -31
- package/agents/scout.md +6 -2
- package/agents/worker.md +12 -3
- package/docs/contracts/esc-abort-v1.md +131 -0
- package/docs/contracts/esc-cascade-v2.md +100 -0
- package/docs/design.md +44 -25
- package/docs/legion-v2-contract.md +461 -0
- package/docs/legion-v2-delivery.md +62 -0
- package/docs/retire-hardening-contract.md +125 -0
- package/docs/subagents-teams.md +76 -0
- package/docs/tui-viewer-contract.md +276 -0
- package/docs/vexillatio-contract.md +162 -0
- package/index.ts +1462 -286
- package/package.json +12 -9
- package/skills/pi-legion/SKILL.md +74 -96
- package/src/agents/agents.ts +6 -11
- package/src/agents/overrides.ts +2 -1
- package/src/agents/paths.ts +1 -1
- package/src/agents/settings.ts +268 -20
- package/src/api.ts +47 -48
- package/src/{herdr → backends/legacy}/client.ts +14 -69
- package/src/{shared → backends/legacy}/cursor-chat.ts +2 -2
- package/src/{herdr → backends/legacy}/runner.ts +22 -9
- package/src/backends/legacy/types.ts +138 -0
- package/src/extension/auto-doctor.ts +144 -0
- package/src/extension/budget.ts +2 -2
- package/src/extension/child-guard.ts +214 -201
- package/src/extension/event-bus.ts +263 -0
- package/src/extension/legion-commands.ts +719 -0
- package/src/extension/legion-doctor.ts +888 -0
- package/src/extension/legion-stop.ts +150 -0
- package/src/extension/legion-tools.ts +816 -0
- package/src/extension/notify.ts +24 -19
- package/src/extension/playbook.ts +20 -89
- package/src/extension/recycle.ts +2 -7
- package/src/extension/runtime.ts +655 -105
- package/src/extension/scout-guard.ts +483 -0
- package/src/extension/slash.ts +89 -438
- package/src/extension/spawn-hints.ts +79 -0
- package/src/extension/summary.ts +17 -58
- package/src/legion/budget.ts +165 -0
- package/src/legion/db.ts +242 -0
- package/src/legion/events.ts +150 -0
- package/src/legion/mail.ts +412 -0
- package/src/legion/nodes.ts +461 -0
- package/src/legion/rebuild.ts +533 -0
- package/src/legion/state-machine.ts +313 -0
- package/src/legion/usage.ts +101 -0
- package/src/runs/acceptance.ts +82 -25
- package/src/runs/args.ts +51 -5
- package/src/runs/hygiene.ts +335 -0
- package/src/runs/orchestrator.ts +790 -1683
- package/src/runs/owner.ts +61 -0
- package/src/runs/provision.ts +135 -0
- package/src/runs/store.ts +44 -17
- package/src/runs/verify-queue.ts +195 -0
- package/src/shared/name.ts +29 -0
- package/src/shared/output-cap.ts +57 -0
- package/src/shared/progress.ts +57 -135
- package/src/shared/session.ts +243 -171
- package/src/shared/types.ts +107 -208
- package/src/supervisor/child-memory.ts +140 -0
- package/src/supervisor/proc-tree.ts +103 -0
- package/src/supervisor/rpc-supervisor.ts +1033 -0
- package/src/supervisor/types.ts +153 -0
- package/src/supervisor/ui-proxy.ts +226 -0
- package/src/tui/chat-items.ts +330 -0
- package/src/tui/live-overlay.ts +874 -0
- package/src/tui/status.ts +553 -27
- package/src/tui/tree-view.ts +808 -0
- package/src/tui/viz/canvas.ts +119 -0
- package/src/tui/viz/layout.ts +111 -0
- package/src/tui/viz/model.ts +123 -0
- package/src/tui/viz/palette.ts +188 -0
- package/src/tui/viz/panels.ts +530 -0
- package/src/tui/viz/railway.ts +313 -0
- package/src/tui/viz/rate.ts +211 -0
- package/src/tui/viz/view.ts +228 -0
- package/src/extension/parent-label.ts +0 -115
- package/src/profiles/classify.ts +0 -375
- package/src/profiles/profiles.ts +0 -730
- package/src/runs/layout.ts +0 -258
package/index.ts
CHANGED
|
@@ -16,9 +16,12 @@ import type { AgentToolResult, AgentToolUpdateCallback } from "@earendil-works/p
|
|
|
16
16
|
import type {
|
|
17
17
|
ExtensionAPI,
|
|
18
18
|
ExtensionContext,
|
|
19
|
+
ExtensionUIContext,
|
|
19
20
|
} from "@earendil-works/pi-coding-agent";
|
|
20
21
|
import { Type, type Static } from "typebox";
|
|
21
|
-
import {
|
|
22
|
+
import { RpcSupervisor } from "./src/supervisor/rpc-supervisor.ts";
|
|
23
|
+
import type { LegionSupervisor } from "./src/supervisor/types.ts";
|
|
24
|
+
import { InMemoryUIProxy, type UIProxy } from "./src/supervisor/ui-proxy.ts";
|
|
22
25
|
import { discoverAgents, findAgent, formatAgentRoster, BUILTIN_AGENTS_DIR } from "./src/agents/agents.ts";
|
|
23
26
|
import {
|
|
24
27
|
applyAgentOverrides,
|
|
@@ -28,6 +31,7 @@ import {
|
|
|
28
31
|
import {
|
|
29
32
|
applyTeam,
|
|
30
33
|
DEFAULT_TEAM,
|
|
34
|
+
TEAM_ENV,
|
|
31
35
|
type ActiveTeam,
|
|
32
36
|
} from "./src/agents/teams.ts";
|
|
33
37
|
import { resolveModel } from "./src/agents/model-resolution.ts";
|
|
@@ -37,39 +41,53 @@ import {
|
|
|
37
41
|
loadSubagentSettings,
|
|
38
42
|
resolveSubagentSettings,
|
|
39
43
|
} from "./src/agents/settings.ts";
|
|
40
|
-
import { createSessionRuntime, type SessionRuntime, shouldRecycleAfterCollect, type WaitResult } from "./src/extension/runtime.ts";
|
|
44
|
+
import { createSessionRuntime, renderLiveChildStatus, type SessionRuntime, type TrackedJob, shouldRecycleAfterCollect, type WaitResult } from "./src/extension/runtime.ts";
|
|
45
|
+
import { createDbEventSource, createLegionEventBus, type DbEventSource, type LegionEventBus } from "./src/extension/event-bus.ts";
|
|
46
|
+
import { LegionPostman, registerLegionTools, type LegionMailCommand } from "./src/extension/legion-tools.ts";
|
|
47
|
+
import { LEGION_DB_ENV, LEGION_NODE_ID_ENV } from "./src/runs/args.ts";
|
|
48
|
+
import type { VerifyRunner } from "./src/runs/acceptance.ts";
|
|
49
|
+
import { legionDbPath, openLegionDb, type DatabaseSync } from "./src/legion/db.ts";
|
|
50
|
+
import { appendEvent } from "./src/legion/events.ts";
|
|
51
|
+
import { attachUsageTotals, upsertUsage } from "./src/legion/usage.ts";
|
|
52
|
+
import { getChildByName, getChildren, getNode, getSubtree, insertNode, type LegionNode, updateNode } from "./src/legion/nodes.ts";
|
|
53
|
+
import { DEFAULT_LEGION_SETTINGS, type LegionSettings } from "./src/shared/types.ts";
|
|
54
|
+
import { parseNestedPathEnv } from "./src/shared/nested-path.ts";
|
|
55
|
+
import { allocateSiblingNames, makeName } from "./src/shared/name.ts";
|
|
56
|
+
import { deriveOutcome, isLastTurnComplete, parseSessionFile } from "./src/shared/session.ts";
|
|
57
|
+
import { restoreHostNodeOptions } from "./src/supervisor/child-memory.ts";
|
|
58
|
+
import { setVerifyQueueListener } from "./src/runs/verify-queue.ts";
|
|
59
|
+
import { applyEventRetention, checkpoint, formatHygieneReport, hygieneEnabled, runStartupHygiene } from "./src/runs/hygiene.ts";
|
|
60
|
+
import {
|
|
61
|
+
createStatusNodeOpener,
|
|
62
|
+
publishSupervisorEvent,
|
|
63
|
+
publishUIRequest,
|
|
64
|
+
registerLegionCommand,
|
|
65
|
+
type RpcEventSource,
|
|
66
|
+
confirmAndStopNode,
|
|
67
|
+
} from "./src/extension/legion-commands.ts";
|
|
68
|
+
import { createIdleEscStop, liveJobs } from "./src/extension/legion-stop.ts";
|
|
41
69
|
import { DEFAULT_FLUSH_MS, DEFAULT_JOIN_MODE } from "./src/extension/join.ts";
|
|
42
70
|
import { registerChildGuard } from "./src/extension/child-guard.ts";
|
|
71
|
+
import { registerScoutGuard } from "./src/extension/scout-guard.ts";
|
|
43
72
|
import { ALLOW_NESTED_ENV } from "./src/extension/budget.ts";
|
|
44
73
|
import {
|
|
45
74
|
applyOnBlockedPolicy,
|
|
46
75
|
followUpFor,
|
|
47
76
|
} from "./src/extension/blocked.ts";
|
|
48
77
|
import { formatAlreadyRecycled } from "./src/extension/recycle.ts";
|
|
49
|
-
import { ParentPaneLabeler } from "./src/extension/parent-label.ts";
|
|
50
78
|
import {
|
|
51
79
|
SUBAGENT_NOTIFY_TYPE,
|
|
52
80
|
} from "./src/extension/notify.ts";
|
|
53
81
|
import { renderSubagentNotice } from "./src/extension/notice-renderer.ts";
|
|
54
|
-
import {
|
|
55
|
-
registerProfileCommands,
|
|
56
|
-
registerAgentsCommand,
|
|
57
|
-
registerTeamCommand,
|
|
58
|
-
registerToggleCommand,
|
|
59
|
-
} from "./src/extension/slash.ts";
|
|
60
|
-
import { registerSummaryCommand } from "./src/extension/summary.ts";
|
|
61
|
-
import {
|
|
62
|
-
blockMessage,
|
|
63
|
-
forbiddenDispatchReason,
|
|
64
|
-
PARENT_PLAYBOOK,
|
|
65
|
-
TOOL_DESCRIPTION,
|
|
66
|
-
} from "./src/extension/playbook.ts";
|
|
82
|
+
import { PARENT_PLAYBOOK, TOOL_DESCRIPTION } from "./src/extension/playbook.ts";
|
|
67
83
|
import { getAgentDir } from "./src/agents/paths.ts";
|
|
68
|
-
import {
|
|
84
|
+
import { expectedModelsFromSettings, runLegionDoctor } from "./src/extension/legion-doctor.ts";
|
|
85
|
+
import { readFileSync } from "node:fs";
|
|
69
86
|
import {
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
87
|
+
effectiveMaxDepth,
|
|
88
|
+
Orchestrator,
|
|
89
|
+
type OrchestratorLegionContext,
|
|
90
|
+
} from "./src/runs/orchestrator.ts";
|
|
73
91
|
import { RunStore, pickChildByName } from "./src/runs/store.ts";
|
|
74
92
|
import { resolveLaunchWorktree } from "./src/runs/worktree.ts";
|
|
75
93
|
import {
|
|
@@ -78,22 +96,18 @@ import {
|
|
|
78
96
|
DEFAULTS,
|
|
79
97
|
DEFAULT_ON_BLOCKED,
|
|
80
98
|
ErrorCodes,
|
|
81
|
-
type HerdrClient,
|
|
82
99
|
type ModelOrigin,
|
|
83
|
-
type Placement,
|
|
84
100
|
SubagentError,
|
|
85
101
|
} from "./src/shared/types.ts";
|
|
86
102
|
|
|
103
|
+
// The parent turn owns its child lifetime. Parallel tool aborts in one turn
|
|
104
|
+
// share this graceful-retire cascade; turn_start begins a fresh cycle.
|
|
105
|
+
let cascadePromise: Promise<void> | undefined;
|
|
106
|
+
|
|
87
107
|
// ---------------------------------------------------------------------------
|
|
88
108
|
// Parameters
|
|
89
109
|
// ---------------------------------------------------------------------------
|
|
90
110
|
|
|
91
|
-
const PlacementSchema = Type.Union([
|
|
92
|
-
Type.Literal("split-down"),
|
|
93
|
-
Type.Literal("split-right"),
|
|
94
|
-
Type.Literal("new-tab"),
|
|
95
|
-
]);
|
|
96
|
-
|
|
97
111
|
const TaskItem = Type.Object({
|
|
98
112
|
agent: Type.String({ description: "Name of the agent to invoke" }),
|
|
99
113
|
task: Type.String({ description: "Task to delegate" }),
|
|
@@ -108,6 +122,12 @@ const TaskItem = Type.Object({
|
|
|
108
122
|
"Parent decides isolation for this child. true = own git branch, child opens an MR. false = write the current checkout.",
|
|
109
123
|
}),
|
|
110
124
|
),
|
|
125
|
+
verify: Type.Optional(
|
|
126
|
+
Type.Array(Type.String(), {
|
|
127
|
+
description:
|
|
128
|
+
"Commands the extension runs itself in the child worktree at collect time; first non-zero exit rejects the verdict.",
|
|
129
|
+
}),
|
|
130
|
+
),
|
|
111
131
|
});
|
|
112
132
|
|
|
113
133
|
const SubagentParams = Type.Object({
|
|
@@ -123,8 +143,10 @@ const SubagentParams = Type.Object({
|
|
|
123
143
|
Type.Literal("collect"),
|
|
124
144
|
Type.Literal("list"),
|
|
125
145
|
Type.Literal("wait"),
|
|
146
|
+
Type.Literal("stop"),
|
|
147
|
+
Type.Literal("diagnose"),
|
|
126
148
|
],
|
|
127
|
-
{ description: "Defaults to launch." },
|
|
149
|
+
{ description: "Defaults to launch. diagnose = Legion doctor full self-check (extensions/models/spawn/memory/tools/storage/runtime)." },
|
|
128
150
|
),
|
|
129
151
|
),
|
|
130
152
|
agent: Type.Optional(
|
|
@@ -146,13 +168,18 @@ const SubagentParams = Type.Object({
|
|
|
146
168
|
Type.String({ description: "Named kind+model+thinking preset" }),
|
|
147
169
|
),
|
|
148
170
|
cwd: Type.Optional(Type.String()),
|
|
149
|
-
placement: Type.Optional(PlacementSchema),
|
|
150
171
|
worktree: Type.Optional(
|
|
151
172
|
Type.Boolean({
|
|
152
173
|
description:
|
|
153
174
|
"Parent decides isolation. true = isolated git branch, child opens an MR. false = edit the current checkout. Omit to use the role default (bundled worker: true).",
|
|
154
175
|
}),
|
|
155
176
|
),
|
|
177
|
+
verify: Type.Optional(
|
|
178
|
+
Type.Array(Type.String(), {
|
|
179
|
+
description:
|
|
180
|
+
"Verification commands the extension runs itself in the child worktree at collect time (launch only). Each must exit 0; the first non-zero exit rejects the child's verdict as `rejected (verified-path)`. The worker does not need to paste test output.",
|
|
181
|
+
}),
|
|
182
|
+
),
|
|
156
183
|
agentScope: Type.Optional(
|
|
157
184
|
Type.Union(
|
|
158
185
|
[Type.Literal("user"), Type.Literal("project"), Type.Literal("both")],
|
|
@@ -162,26 +189,40 @@ const SubagentParams = Type.Object({
|
|
|
162
189
|
),
|
|
163
190
|
),
|
|
164
191
|
name: Type.Optional(
|
|
165
|
-
Type.String({ description: "Child handle name (control actions)" }),
|
|
192
|
+
Type.String({ description: "Child handle name (control actions, including wait and stop)" }),
|
|
166
193
|
),
|
|
167
194
|
all: Type.Optional(
|
|
168
195
|
Type.Boolean({
|
|
169
|
-
description: "wait: wait for every
|
|
196
|
+
description: "wait: wait for active children; stop: stop every non-retired child",
|
|
170
197
|
}),
|
|
171
198
|
),
|
|
172
199
|
timeoutMs: Type.Optional(
|
|
173
200
|
Type.Number({
|
|
174
|
-
description: "wait: per-child timeout in ms (default: role turnTimeoutMs)",
|
|
201
|
+
description: "wait: per-child timeout in ms; event-driven completion returns early (default: role turnTimeoutMs)",
|
|
175
202
|
}),
|
|
176
203
|
),
|
|
177
204
|
message: Type.Optional(
|
|
178
205
|
Type.String({ description: "Text for steer/continue" }),
|
|
179
206
|
),
|
|
207
|
+
team: Type.Optional(
|
|
208
|
+
Type.String({ description: "Team to use for the launched child and its nested roster" }),
|
|
209
|
+
),
|
|
180
210
|
});
|
|
181
211
|
|
|
182
212
|
/** The validated tool parameters, as the model supplies them. */
|
|
183
213
|
type SubagentParams = Static<typeof SubagentParams>;
|
|
184
214
|
|
|
215
|
+
const VexillatioParams = Type.Object({
|
|
216
|
+
task: Type.String({ description: "Recon task: one clear question list" }),
|
|
217
|
+
agent: Type.Optional(Type.String({ description: "Utility agent name (default: scout)" })),
|
|
218
|
+
tasks: Type.Optional(Type.Array(TaskItem, { description: "Parallel recon launches" })),
|
|
219
|
+
cwd: Type.Optional(Type.String()),
|
|
220
|
+
model: Type.Optional(Type.String()),
|
|
221
|
+
timeoutMs: Type.Optional(Type.Number({ description: "Per-child wait in ms, default 600000" })),
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
const UTILITY_CHILD_ENV = "PI_SUBAGENT_UTILITY_CHILD";
|
|
225
|
+
|
|
185
226
|
const blockedUi = new WeakMap<
|
|
186
227
|
SessionRuntime,
|
|
187
228
|
{
|
|
@@ -194,51 +235,495 @@ const blockedUi = new WeakMap<
|
|
|
194
235
|
// Extension
|
|
195
236
|
// ---------------------------------------------------------------------------
|
|
196
237
|
|
|
197
|
-
export
|
|
238
|
+
export interface ExtensionSupervisorDeps {
|
|
239
|
+
supervisor?: LegionSupervisor;
|
|
240
|
+
uiProxy?: UIProxy;
|
|
241
|
+
onBudgetRefused?: NonNullable<ConstructorParameters<typeof Orchestrator>[0]["onBudgetRefused"]>;
|
|
242
|
+
/** Injected verify runner for tests; production falls back to the real shell runner. */
|
|
243
|
+
verifyRunner?: VerifyRunner;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
interface LegionRuntimeIdentity {
|
|
247
|
+
pi: ExtensionAPI;
|
|
248
|
+
db: DatabaseSync;
|
|
249
|
+
dbPath: string;
|
|
250
|
+
runStore: RunStore;
|
|
251
|
+
nodeId: string;
|
|
252
|
+
parentNodeId: string | null;
|
|
253
|
+
parentRunId: string | null;
|
|
254
|
+
runId: string;
|
|
255
|
+
runDir: string;
|
|
256
|
+
rootRunId: string;
|
|
257
|
+
storeRootDir: string;
|
|
258
|
+
settings: LegionSettings;
|
|
259
|
+
eventBus: LegionEventBus;
|
|
260
|
+
dbEventSource: DbEventSource;
|
|
261
|
+
postman: LegionPostman;
|
|
262
|
+
childNodeId(name: string): string | undefined;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function settingsForLegion(settings: ReturnType<typeof loadSubagentSettings>): LegionSettings {
|
|
266
|
+
return { ...DEFAULT_LEGION_SETTINGS, ...(settings.legion ?? {}), phaseTimeouts: { ...DEFAULT_LEGION_SETTINGS.phaseTimeouts, ...(settings.legion?.phaseTimeouts ?? {}) } };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function runStoreFor(cwd: string): RunStore {
|
|
270
|
+
const sharedDbPath = process.env[LEGION_DB_ENV];
|
|
271
|
+
if (sharedDbPath && path.isAbsolute(sharedDbPath)) {
|
|
272
|
+
const storeRoot = path.dirname(path.dirname(path.dirname(sharedDbPath)));
|
|
273
|
+
return new RunStore({ rootDir: storeRoot });
|
|
274
|
+
}
|
|
275
|
+
return new RunStore({ rootDir: path.join(cwd, ".pi-subagents") });
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function notice(pi: ExtensionAPI, text: string): void {
|
|
279
|
+
pi.sendMessage({ customType: SUBAGENT_NOTIFY_TYPE, content: text, display: true }, { triggerTurn: true, deliverAs: "followUp" });
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function createLegionRuntime(input: {
|
|
283
|
+
pi: ExtensionAPI;
|
|
284
|
+
supervisor: LegionSupervisor;
|
|
285
|
+
db: DatabaseSync;
|
|
286
|
+
dbPath: string;
|
|
287
|
+
nodeId: string;
|
|
288
|
+
rootRunId: string;
|
|
289
|
+
runDir: string;
|
|
290
|
+
cwd: string;
|
|
291
|
+
storeRootDir: string;
|
|
292
|
+
settings: LegionSettings;
|
|
293
|
+
eventBus: LegionEventBus;
|
|
294
|
+
registerTools: boolean;
|
|
295
|
+
}): LegionRuntimeIdentity {
|
|
296
|
+
const self = getNode(input.db, input.nodeId);
|
|
297
|
+
const eventBus = input.eventBus;
|
|
298
|
+
const dbEventSource = createDbEventSource(input.db);
|
|
299
|
+
dbEventSource.start(eventBus);
|
|
300
|
+
const postman = new LegionPostman({
|
|
301
|
+
db: input.db,
|
|
302
|
+
nodeId: input.nodeId,
|
|
303
|
+
supervisor: input.supervisor,
|
|
304
|
+
settings: input.settings,
|
|
305
|
+
eventBus,
|
|
306
|
+
sendRootMessage: (text: string) => notice(input.pi, text),
|
|
307
|
+
sendSelfMessage: (text: string) => notice(input.pi, text),
|
|
308
|
+
resumeChild: (node: LegionNode, mail: LegionMailCommand) => resumeLegionChild({
|
|
309
|
+
pi: input.pi, supervisor: input.supervisor, db: input.db, dbPath: input.dbPath,
|
|
310
|
+
eventBus, settings: input.settings, cwd: input.cwd, storeRootDir: input.storeRootDir,
|
|
311
|
+
parentNodeId: input.nodeId, node, message: mail.text,
|
|
312
|
+
}),
|
|
313
|
+
});
|
|
314
|
+
const identity: LegionRuntimeIdentity = {
|
|
315
|
+
pi: input.pi,
|
|
316
|
+
db: input.db,
|
|
317
|
+
dbPath: input.dbPath,
|
|
318
|
+
runStore: new RunStore({ rootDir: input.storeRootDir }),
|
|
319
|
+
nodeId: input.nodeId,
|
|
320
|
+
parentNodeId: self?.parentId ?? null,
|
|
321
|
+
parentRunId: self?.parentId ? getNode(input.db, self.parentId)?.runId ?? null : null,
|
|
322
|
+
runId: self?.runId ?? input.rootRunId,
|
|
323
|
+
runDir: input.runDir,
|
|
324
|
+
rootRunId: input.rootRunId,
|
|
325
|
+
storeRootDir: input.storeRootDir,
|
|
326
|
+
settings: input.settings,
|
|
327
|
+
eventBus,
|
|
328
|
+
dbEventSource,
|
|
329
|
+
postman,
|
|
330
|
+
childNodeId: (name) => getChildByName(input.db, input.nodeId, name)?.id,
|
|
331
|
+
};
|
|
332
|
+
if (input.registerTools && input.nodeId !== "root") registerLegionTools(input.pi, { db: input.db, nodeId: input.nodeId, role: self?.role, settings: input.settings, cwd: input.cwd, runDir: input.runDir, eventBus, sendSelfMessage: (text: string) => notice(input.pi, text) });
|
|
333
|
+
postman.start();
|
|
334
|
+
return identity;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function resolveLegionIdentity(input: {
|
|
338
|
+
pi: ExtensionAPI;
|
|
339
|
+
supervisor: LegionSupervisor;
|
|
340
|
+
store: RunStore;
|
|
341
|
+
cwd: string;
|
|
342
|
+
settings: ReturnType<typeof loadSubagentSettings>;
|
|
343
|
+
eventBus: LegionEventBus;
|
|
344
|
+
cached?: LegionRuntimeIdentity;
|
|
345
|
+
/** This process's session file (identity credential). From ctx.sessionManager. */
|
|
346
|
+
sessionFile?: string | undefined;
|
|
347
|
+
}): LegionRuntimeIdentity | undefined {
|
|
348
|
+
const dbPath = process.env[LEGION_DB_ENV];
|
|
349
|
+
const nodeId = process.env[LEGION_NODE_ID_ENV];
|
|
350
|
+
if (Boolean(dbPath) !== Boolean(nodeId)) throw new Error("PI_LEGION_DB and PI_LEGION_NODE_ID must be set together");
|
|
351
|
+
if (dbPath && nodeId) {
|
|
352
|
+
if (!path.isAbsolute(dbPath)) throw new Error("PI_LEGION_DB must be an absolute path");
|
|
353
|
+
if (input.cached?.dbPath === path.resolve(dbPath) && input.cached.nodeId === nodeId) return input.cached;
|
|
354
|
+
const absoluteDbPath = path.resolve(dbPath);
|
|
355
|
+
const db = openLegionDb(absoluteDbPath);
|
|
356
|
+
const node = getNode(db, nodeId);
|
|
357
|
+
if (!node) { db.close(); throw new Error(`legion node not found: ${nodeId}`); }
|
|
358
|
+
// Identity credential check: the node's recorded sessionFile must match
|
|
359
|
+
// THIS process's session. A child env (PI_LEGION_DB/PI_LEGION_NODE_ID)
|
|
360
|
+
// inherited by an arbitrary subprocess (e.g. a scout running `npm test`
|
|
361
|
+
// whose tests load this extension) must not adopt the scout's tree
|
|
362
|
+
// identity — that produced the "healthy ghost nodes" incident: fake
|
|
363
|
+
// launches persisted into the live ledger. A mismatch means the env
|
|
364
|
+
// belongs to an ancestor process, not to us; refuse to attach.
|
|
365
|
+
const mySession = input.sessionFile;
|
|
366
|
+
if (mySession && node.sessionFile && path.resolve(mySession) !== path.resolve(node.sessionFile)) {
|
|
367
|
+
db.close();
|
|
368
|
+
throw new Error(
|
|
369
|
+
`legion identity mismatch: node ${nodeId} belongs to session ${node.sessionFile}, ` +
|
|
370
|
+
`but this process runs ${mySession}; inherited PI_LEGION_* env is not yours to adopt`,
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
const nodeRuns = input.store.listRuns().filter((run) => run.nodeId === nodeId || run.herdr.node_id === nodeId);
|
|
374
|
+
let nodeRun = nodeRuns.at(-1);
|
|
375
|
+
if (!nodeRun) {
|
|
376
|
+
nodeRun = input.store.createRun({ task: node.taskSummary ?? `Resume ${node.role} node ${node.id}`, cwd: input.cwd, path: parseNestedPathEnv(process.env.PI_SUBAGENT_PARENT_PATH), maxDepth: effectiveMaxDepth(input.settings.legion?.maxDepth), nodeId, herdr: { supervisor: "rpc", node_id: nodeId } });
|
|
377
|
+
}
|
|
378
|
+
if (node.runId !== nodeRun.runId) updateLegionRunId(db, nodeId, nodeRun.runId);
|
|
379
|
+
const identity = createLegionRuntime({ pi: input.pi, supervisor: input.supervisor, db, dbPath: absoluteDbPath, nodeId, rootRunId: getNode(db, "root")?.runId ?? path.basename(path.dirname(absoluteDbPath)), runDir: input.store.runDir(nodeRun.runId), cwd: input.cwd, storeRootDir: input.store.rootDir, settings: settingsForLegion(input.settings), eventBus: input.eventBus, registerTools: true });
|
|
380
|
+
if (node.role === "centurion") void resumeLegionTeam({ identity, input });
|
|
381
|
+
return identity;
|
|
382
|
+
}
|
|
383
|
+
if (input.cached?.nodeId === "root" && path.resolve(input.cached.storeRootDir) === path.resolve(input.store.rootDir)) return input.cached;
|
|
384
|
+
// A root runtime belongs to the current Pi session. Do not adopt a different
|
|
385
|
+
// session's most recent tree merely because it shares the same project cwd.
|
|
386
|
+
return undefined;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function updateLegionRunId(db: DatabaseSync, nodeId: string, runId: string): void {
|
|
390
|
+
db.prepare("UPDATE nodes SET run_id = ?, updated_at = ? WHERE id = ?").run(runId, Date.now(), nodeId);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
async function resumeLegionTeam(input: { identity: LegionRuntimeIdentity; input: { pi: ExtensionAPI; supervisor: LegionSupervisor; cwd: string } }): Promise<void> {
|
|
394
|
+
for (const node of getChildren(input.identity.db, input.identity.nodeId)) {
|
|
395
|
+
if (["settled", "failed"].includes(node.status) || ["done", "failed", "aborted"].includes(node.phase ?? "") || input.input.supervisor.isAlive(node.name)) continue;
|
|
396
|
+
if (node.sessionFile) {
|
|
397
|
+
const parsed = parseSessionFile(node.sessionFile);
|
|
398
|
+
const recovered = deriveOutcome(parsed);
|
|
399
|
+
if (isLastTurnComplete(parsed) && recovered.status === "success") {
|
|
400
|
+
updateNode(input.identity.db, node.id, { status: "settled" });
|
|
401
|
+
appendEvent(input.identity.db, node.id, "node_settled", { recoveredFromSession: true, execution: recovered.status });
|
|
402
|
+
const usage = recovered.usage;
|
|
403
|
+
if (usage) upsertUsage(input.identity.db, { nodeId: node.id, tokensIn: usage.input + usage.cacheRead + usage.cacheWrite, tokensOut: usage.output, costUsd: usage.cost });
|
|
404
|
+
continue;
|
|
405
|
+
}
|
|
406
|
+
if (isLastTurnComplete(parsed) && recovered.status !== "running" && recovered.status !== "unknown") {
|
|
407
|
+
updateNode(input.identity.db, node.id, { status: "failed" });
|
|
408
|
+
appendEvent(input.identity.db, node.id, "node_failed", { recoveredFromSession: true, execution: recovered.status, reason: recovered.reason });
|
|
409
|
+
continue;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
try {
|
|
413
|
+
await resumeLegionChild({ pi: input.input.pi, supervisor: input.input.supervisor, db: input.identity.db, dbPath: input.identity.dbPath, eventBus: input.identity.eventBus, settings: input.identity.settings, cwd: input.input.cwd, storeRootDir: input.identity.storeRootDir, parentNodeId: input.identity.nodeId, node, message: `Resume from saved session ${node.sessionFile} and continue interrupted work.` });
|
|
414
|
+
} catch (error) { notice(input.input.pi, `Could not resume ${node.id}: ${error instanceof Error ? error.message : String(error)}`); }
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function createRootLegion(input: { pi: ExtensionAPI; supervisor: LegionSupervisor; store: RunStore; cwd: string; task: string; settings: ReturnType<typeof loadSubagentSettings>; maxDepth: number; eventBus: LegionEventBus }): LegionRuntimeIdentity {
|
|
419
|
+
const rootRun = input.store.createRun({ task: input.task, cwd: input.cwd, maxDepth: input.maxDepth, nodeId: "root", herdr: { supervisor: "rpc" } });
|
|
420
|
+
const dbPath = path.resolve(legionDbPath(input.store.runDir(rootRun.runId)));
|
|
421
|
+
const db = openLegionDb(dbPath);
|
|
422
|
+
const now = Date.now();
|
|
423
|
+
insertNode(db, { id: "root", parentId: null, name: "root", role: "root", kind: "pi", depth: 0, runId: rootRun.runId, status: "running", taskSummary: input.task.slice(0, 2_000), createdAt: now, updatedAt: now }, () => now);
|
|
424
|
+
appendEvent(db, "root", "node_launched", { runId: rootRun.runId, task: input.task }, { now: () => now });
|
|
425
|
+
return createLegionRuntime({ pi: input.pi, supervisor: input.supervisor, db, dbPath, nodeId: "root", rootRunId: rootRun.runId, runDir: input.store.runDir(rootRun.runId), cwd: input.cwd, storeRootDir: input.store.rootDir, settings: settingsForLegion(input.settings), eventBus: input.eventBus, registerTools: false });
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
async function resumeLegionChild(input: { pi: ExtensionAPI; supervisor: LegionSupervisor; db: DatabaseSync; dbPath: string; eventBus: LegionEventBus; settings: LegionSettings; cwd: string; storeRootDir: string; parentNodeId: string; node: LegionNode; message: string }): Promise<void> {
|
|
429
|
+
const parent = getNode(input.db, input.parentNodeId);
|
|
430
|
+
if (!parent || !input.node.runId || !input.node.sessionFile) throw new Error(`resume credentials missing for ${input.node.id}`);
|
|
431
|
+
const store = new RunStore({ rootDir: input.storeRootDir });
|
|
432
|
+
const nodeRun = store.readRun(input.node.runId);
|
|
433
|
+
if (!nodeRun) throw new Error(`resume run.json not found: ${input.node.runId}`);
|
|
434
|
+
const parentRunId = parent.runId;
|
|
435
|
+
if (!parentRunId) throw new Error(`parent run id missing for ${input.node.id}`);
|
|
436
|
+
let parentRun = store.readRun(parentRunId);
|
|
437
|
+
if (!parentRun) throw new Error(`parent run.json not found: ${parentRunId}`);
|
|
438
|
+
let saved = parentRun.children.find((child) => child.nodeId === input.node.id || child.sessionFile === input.node.sessionFile);
|
|
439
|
+
if (!saved) {
|
|
440
|
+
await store.addChild(parentRunId, {
|
|
441
|
+
name: input.node.name,
|
|
442
|
+
nodeId: input.node.id,
|
|
443
|
+
sessionFile: input.node.sessionFile,
|
|
444
|
+
ownerToken: `legion-${input.node.id}`,
|
|
445
|
+
state: input.node.status === "settled" ? "awaiting" : "retired",
|
|
446
|
+
spawnedAt: new Date(input.node.createdAt).toISOString(),
|
|
447
|
+
agent: input.node.role,
|
|
448
|
+
kind: input.node.kind as AgentConfig["kind"],
|
|
449
|
+
...(input.node.model ? { model: input.node.model } : {}),
|
|
450
|
+
...(input.node.worktreePath ? { worktreePath: input.node.worktreePath } : {}),
|
|
451
|
+
});
|
|
452
|
+
parentRun = store.readRun(parentRunId)!;
|
|
453
|
+
saved = parentRun.children.find((child) => child.nodeId === input.node.id);
|
|
454
|
+
}
|
|
455
|
+
if (!saved) throw new Error(`resume child record not found: ${input.node.id}`);
|
|
456
|
+
const cwd = input.node.worktreePath ?? parent.worktreePath ?? parentRun.cwd ?? input.cwd;
|
|
457
|
+
const catalog = loadCatalog(cwd, cwd, undefined);
|
|
458
|
+
const resumedTeam = input.node.team
|
|
459
|
+
? applyTeam(catalog.allAgents, { ...catalog.settings, team: input.node.team }, { ...process.env, [TEAM_ENV]: input.node.team })
|
|
460
|
+
: { agents: catalog.agents, team: catalog.team, warnings: catalog.teamWarnings };
|
|
461
|
+
const agent = findControlAgent(resumedTeam.agents, catalog.allAgents, input.node.role);
|
|
462
|
+
if (!agent) throw new Error(`resume role no longer exists: ${input.node.role}`);
|
|
463
|
+
const childRun = store.readRun(input.node.runId);
|
|
464
|
+
if (!childRun) throw new Error(`child run.json not found: ${input.node.runId}`);
|
|
465
|
+
const parentOfSupervisor = parent.parentId ? getNode(input.db, parent.parentId) : null;
|
|
466
|
+
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.childContext, parentPath: parentRun.path, 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 } });
|
|
467
|
+
orchestrator.restore(parentRun);
|
|
468
|
+
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) });
|
|
469
|
+
await store.updateChild(parentRun.runId, saved.name, (child) => Object.assign(child, handle.child));
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
export default function herdrSubagents(pi: ExtensionAPI, deps: ExtensionSupervisorDeps = {}) {
|
|
473
|
+
cascadePromise = undefined;
|
|
198
474
|
const isChild = process.env.PI_SUBAGENT_CHILD === "1";
|
|
199
475
|
const allowNested = process.env[ALLOW_NESTED_ENV] === "1";
|
|
476
|
+
if (isChild) restoreHostNodeOptions();
|
|
477
|
+
else {
|
|
478
|
+
// Root UI: surface verify-queue waits instead of a silent stall.
|
|
479
|
+
setVerifyQueueListener((event) => {
|
|
480
|
+
try {
|
|
481
|
+
if (event.phase === "waiting") lastUi?.setStatus("legion-verify", `verify queued ${Math.round(event.waitedMs / 1000)}s · ${event.holders}/${event.limit} slots busy`);
|
|
482
|
+
else lastUi?.setStatus("legion-verify", undefined);
|
|
483
|
+
} catch { /* no UI */ }
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
const supervisor = deps.supervisor ?? new RpcSupervisor({
|
|
487
|
+
onSpawnRefused: (message) => {
|
|
488
|
+
try { lastUi?.notify(`pi-legion: ${message}`, "error"); } catch { /* no UI (child / print mode) */ }
|
|
489
|
+
},
|
|
490
|
+
});
|
|
491
|
+
const verifyRunner = deps.verifyRunner;
|
|
492
|
+
const legionBus = createLegionEventBus();
|
|
493
|
+
const rpcUIProxy = (supervisor as LegionSupervisor & { uiProxy?: UIProxy }).uiProxy;
|
|
494
|
+
const uiProxy = deps.uiProxy ?? rpcUIProxy ?? new InMemoryUIProxy();
|
|
495
|
+
const onBudgetRefused = deps.onBudgetRefused ?? ((event) => {
|
|
496
|
+
try { pi.events.emit("pi-legion:budget_refused", event); } catch { /* optional host event */ }
|
|
497
|
+
});
|
|
498
|
+
let sessionEnabledOverride: boolean | undefined;
|
|
499
|
+
const subagentsEnabled = (cwd: string): boolean => {
|
|
500
|
+
if (sessionEnabledOverride !== undefined) return sessionEnabledOverride;
|
|
501
|
+
try {
|
|
502
|
+
return loadSubagentSettings({
|
|
503
|
+
userSettingsPath: path.join(getAgentDir(), "settings.json"),
|
|
504
|
+
projectSettingsPath: path.join(cwd, ".pi", "settings.json"),
|
|
505
|
+
}).enabled !== false;
|
|
506
|
+
} catch (error) {
|
|
507
|
+
console.warn(
|
|
508
|
+
"[pi-legion] Unable to load settings while checking whether subagents are enabled; defaulting to enabled.",
|
|
509
|
+
error,
|
|
510
|
+
);
|
|
511
|
+
return true;
|
|
512
|
+
}
|
|
513
|
+
};
|
|
514
|
+
let lastCwd = process.cwd();
|
|
515
|
+
let legionIdentity: LegionRuntimeIdentity | undefined;
|
|
516
|
+
const stopLegionRuntime = (): void => {
|
|
517
|
+
if (!legionIdentity) return;
|
|
518
|
+
legionIdentity.postman.stop();
|
|
519
|
+
legionIdentity.dbEventSource.stop();
|
|
520
|
+
// #40: bound the ledger and fold the WAL back before closing.
|
|
521
|
+
if (hygieneEnabled() && !isChild) applyEventRetention(legionIdentity.db);
|
|
522
|
+
else checkpoint(legionIdentity.db);
|
|
523
|
+
legionIdentity.db.close();
|
|
524
|
+
legionIdentity = undefined;
|
|
525
|
+
};
|
|
526
|
+
const initializeLegionRuntime = (ctx: Pick<ExtensionContext, "cwd"> & { sessionFile?: string }): void => {
|
|
527
|
+
const store = runStoreFor(ctx.cwd);
|
|
528
|
+
const settings = loadSubagentSettings({
|
|
529
|
+
userSettingsPath: path.join(getAgentDir(), "settings.json"),
|
|
530
|
+
projectSettingsPath: path.join(ctx.cwd, ".pi", "settings.json"),
|
|
531
|
+
});
|
|
532
|
+
const next = resolveLegionIdentity({ pi, supervisor, store, cwd: ctx.cwd, settings, eventBus: legionBus, cached: legionIdentity, sessionFile: ctx.sessionFile });
|
|
533
|
+
if (legionIdentity && next !== legionIdentity) stopLegionRuntime();
|
|
534
|
+
legionIdentity = next;
|
|
535
|
+
};
|
|
200
536
|
if (isChild) {
|
|
201
537
|
registerChildGuard(pi);
|
|
202
|
-
if (
|
|
538
|
+
if (process.env[LEGION_DB_ENV] && process.env[LEGION_NODE_ID_ENV]) {
|
|
539
|
+
// Boot-time attach: no ExtensionContext yet, so no session credential —
|
|
540
|
+
// the session_start handler re-runs this with the real credential.
|
|
541
|
+
try { initializeLegionRuntime({ cwd: process.cwd() }); }
|
|
542
|
+
catch (error) { console.error(`[pi-legion] legion initialization failed: ${error instanceof Error ? error.message : String(error)}`); }
|
|
543
|
+
}
|
|
203
544
|
}
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
545
|
+
let hygieneScheduled = false;
|
|
546
|
+
pi.on("session_start", (_event, ctx) => {
|
|
547
|
+
try { initializeLegionRuntime({ cwd: ctx.cwd, sessionFile: ctx.sessionManager?.getSessionFile() }); }
|
|
548
|
+
catch (error) { console.error(`[pi-legion] legion initialization failed: ${error instanceof Error ? error.message : String(error)}`); }
|
|
549
|
+
// #37/#38/#40: one deferred, best-effort storage sweep per root process.
|
|
550
|
+
if (!isChild && !hygieneScheduled && hygieneEnabled()) {
|
|
551
|
+
hygieneScheduled = true;
|
|
552
|
+
const storeRoot = runStoreFor(ctx.cwd).rootDir;
|
|
553
|
+
const timer = setTimeout(() => {
|
|
554
|
+
try {
|
|
555
|
+
const report = runStartupHygiene({ storeRoot, protectRunIds: legionIdentity ? [legionIdentity.rootRunId] : [] });
|
|
556
|
+
const text = formatHygieneReport(report);
|
|
557
|
+
if (text) ctx.ui?.notify?.(text, "info");
|
|
558
|
+
} catch (error) {
|
|
559
|
+
console.error(`[pi-legion] storage sweep failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
560
|
+
}
|
|
561
|
+
}, 1_500);
|
|
562
|
+
timer.unref?.();
|
|
563
|
+
}
|
|
564
|
+
});
|
|
565
|
+
const pendingUtilityRetires: Array<() => Promise<void>> = [];
|
|
566
|
+
let sessionRuntime: SessionRuntime | undefined;
|
|
567
|
+
const shutdownRetireAll = async (): Promise<void> => {
|
|
568
|
+
const children = sessionRuntime?.activeJobs() ?? [];
|
|
569
|
+
sessionRuntime?.dispose();
|
|
570
|
+
await Promise.all([
|
|
571
|
+
...children.map((job) => job.retire?.().catch(() => {})),
|
|
572
|
+
...pendingUtilityRetires.map((retire) => retire().catch(() => {})),
|
|
573
|
+
]);
|
|
574
|
+
stopLegionRuntime();
|
|
575
|
+
};
|
|
576
|
+
let cascadingJobs = new Set<TrackedJob>();
|
|
577
|
+
let cascadingUtilityRetires = new Set<() => Promise<void>>();
|
|
578
|
+
const cascadeRetireAll = (): Promise<void> => {
|
|
579
|
+
const jobs = (sessionRuntime?.activeJobs() ?? []).filter(
|
|
580
|
+
(job) => job.state !== "retired" && !cascadingJobs.has(job),
|
|
581
|
+
);
|
|
582
|
+
const utilityRetires = pendingUtilityRetires.filter(
|
|
583
|
+
(retire) => !cascadingUtilityRetires.has(retire),
|
|
584
|
+
);
|
|
585
|
+
if (!cascadePromise) cascadePromise = Promise.resolve();
|
|
586
|
+
if (jobs.length === 0 && utilityRetires.length === 0) return cascadePromise;
|
|
587
|
+
for (const job of jobs) {
|
|
588
|
+
cascadingJobs.add(job);
|
|
589
|
+
sessionRuntime?.cancelWatch(job.name);
|
|
590
|
+
}
|
|
591
|
+
for (const retire of utilityRetires) cascadingUtilityRetires.add(retire);
|
|
592
|
+
const retirements = [
|
|
593
|
+
...jobs.map((job) => async () => job.retire?.()),
|
|
594
|
+
...utilityRetires.map((retire) => async () => retire()),
|
|
595
|
+
];
|
|
596
|
+
cascadePromise = cascadePromise.then(async () => {
|
|
597
|
+
// allSettled waits for every graceful retire without turning a child
|
|
598
|
+
// failure into a user-visible result or skipping runtime release.
|
|
599
|
+
await Promise.allSettled(retirements.map((retire) => retire()));
|
|
600
|
+
for (const job of jobs) sessionRuntime?.release(job.name);
|
|
601
|
+
});
|
|
602
|
+
return cascadePromise;
|
|
603
|
+
};
|
|
604
|
+
// Register before the child early-return paths below: a command-chain child
|
|
605
|
+
// may use vexillatio across multiple turns without creating a session runtime.
|
|
606
|
+
pi.on("turn_start", () => {
|
|
607
|
+
cascadePromise = undefined;
|
|
608
|
+
cascadingJobs = new Set();
|
|
609
|
+
cascadingUtilityRetires = new Set();
|
|
610
|
+
});
|
|
611
|
+
// An async launch has already returned when ESC lands: no subagent-family
|
|
612
|
+
// tool call is in flight to observe the abort signal, so the run's settle
|
|
613
|
+
// event is the last chance to retire this turn's children. Never awaited —
|
|
614
|
+
// a graceful retire may take seconds and must not stall the settle path.
|
|
615
|
+
pi.on("agent_settled", (event) => {
|
|
616
|
+
if (!event.aborted) return;
|
|
617
|
+
void cascadeRetireAll();
|
|
214
618
|
});
|
|
215
|
-
|
|
216
|
-
|
|
619
|
+
if (process.env[UTILITY_CHILD_ENV] !== "1") {
|
|
620
|
+
pi.registerTool({
|
|
621
|
+
name: "vexillatio",
|
|
622
|
+
label: "Vexillatio",
|
|
623
|
+
description: "Launch only read-only utility agents and return their collected output.",
|
|
624
|
+
parameters: VexillatioParams,
|
|
625
|
+
async execute(_id, params, signal, _onUpdate, ctx: ExtensionContext): Promise<AgentToolResult<unknown>> {
|
|
626
|
+
if (signal?.aborted) {
|
|
627
|
+
await cascadeRetireAll();
|
|
628
|
+
return ok("interrupted (parent turn aborted)");
|
|
629
|
+
}
|
|
630
|
+
if (!subagentsEnabled(ctx.cwd)) {
|
|
631
|
+
return fail("subagents are disabled (subagents.enabled: false or /legion off). Re-enable with /legion on.", ErrorCodes.DISABLED);
|
|
632
|
+
}
|
|
633
|
+
const runtime = sessionRuntime;
|
|
634
|
+
if (!runtime) return fail("session runtime is not initialized", ErrorCodes.INVALID_PARAMS);
|
|
635
|
+
runtime.bind(ctx);
|
|
636
|
+
const result = await launchUtilityFamily({
|
|
637
|
+
pi,
|
|
638
|
+
ctx,
|
|
639
|
+
runtime,
|
|
640
|
+
params,
|
|
641
|
+
supervisor,
|
|
642
|
+
uiProxy,
|
|
643
|
+
onBudgetRefused,
|
|
644
|
+
cwd: params.cwd ?? ctx.cwd,
|
|
645
|
+
sessionCwd: ctx.cwd,
|
|
646
|
+
signal,
|
|
647
|
+
isChild,
|
|
648
|
+
legion: legionIdentity,
|
|
649
|
+
eventBus: legionBus,
|
|
650
|
+
onLegion: (identity) => {
|
|
651
|
+
if (legionIdentity && legionIdentity !== identity) stopLegionRuntime();
|
|
652
|
+
legionIdentity = identity;
|
|
653
|
+
},
|
|
654
|
+
onUtilityChild: (retire) => pendingUtilityRetires.push(retire),
|
|
655
|
+
});
|
|
656
|
+
if (signal?.aborted) {
|
|
657
|
+
await cascadeRetireAll();
|
|
658
|
+
return ok("interrupted (parent turn aborted)");
|
|
659
|
+
}
|
|
660
|
+
return result;
|
|
661
|
+
},
|
|
662
|
+
});
|
|
663
|
+
}
|
|
664
|
+
pi.on("before_agent_start", (event, ctx) => {
|
|
665
|
+
if (process.env[UTILITY_CHILD_ENV] === "1") return;
|
|
666
|
+
if (!subagentsEnabled(ctx?.cwd ?? lastCwd)) return;
|
|
667
|
+
const tools = event.systemPromptOptions?.selectedTools;
|
|
668
|
+
if (Array.isArray(tools) && !tools.includes("subagent") && !tools.includes("vexillatio")) return;
|
|
669
|
+
if (ctx?.cwd) lastCwd = ctx.cwd;
|
|
670
|
+
const catalog = loadCatalog(lastCwd, lastCwd, undefined);
|
|
671
|
+
const parentContext = catalog.settings.parentContext?.trim();
|
|
672
|
+
return {
|
|
673
|
+
systemPrompt: `${event.systemPrompt}\n\n${PARENT_PLAYBOOK}\n\n${renderParentRoster(
|
|
674
|
+
catalog.agents,
|
|
675
|
+
catalog.team,
|
|
676
|
+
catalog.teamWarnings,
|
|
677
|
+
)}${parentContext ? `\n\n${parentContext}` : ""}`,
|
|
678
|
+
};
|
|
679
|
+
});
|
|
680
|
+
if (process.env[UTILITY_CHILD_ENV] === "1") {
|
|
681
|
+
pi.on("session_shutdown", () => stopLegionRuntime());
|
|
682
|
+
return;
|
|
683
|
+
}
|
|
684
|
+
let lastUi: ExtensionUIContext | undefined;
|
|
685
|
+
let statusNodeOpener: ((nodeId: string) => void) | undefined;
|
|
217
686
|
let lastConfirm: ((message: string) => Promise<boolean>) | undefined;
|
|
218
|
-
let lastCwd = process.cwd();
|
|
219
687
|
// turn_start/turn_end maintain this so a flush window can wait out a
|
|
220
688
|
// busy parent instead of queueing behind the whole turn (join extends
|
|
221
689
|
// the window at most MAX_BUSY_EXTENSIONS times).
|
|
222
690
|
let parentTurnActive = false;
|
|
691
|
+
const rpcEventSource = supervisor as LegionSupervisor & RpcEventSource;
|
|
223
692
|
const runtime = createSessionRuntime({
|
|
224
693
|
sendMessage: (message, options) => pi.sendMessage(message, options),
|
|
694
|
+
onStatusNodeClick: (nodeId) => statusNodeOpener?.(nodeId),
|
|
695
|
+
statusStrip: process.env.PI_LEGION_STRIP === "tree" ? "tree" : "railway",
|
|
696
|
+
eventBus: legionBus,
|
|
697
|
+
getTreeNodes: () => {
|
|
698
|
+
const identity = legionIdentity;
|
|
699
|
+
return identity
|
|
700
|
+
? attachUsageTotals(identity.db, getSubtree(identity.db, identity.nodeId))
|
|
701
|
+
: [];
|
|
702
|
+
},
|
|
703
|
+
getTreeIdentity: (name) => {
|
|
704
|
+
const node = legionIdentity && getChildByName(legionIdentity.db, legionIdentity.nodeId, name);
|
|
705
|
+
return node ? {
|
|
706
|
+
nodeId: node.id,
|
|
707
|
+
parentId: node.parentId,
|
|
708
|
+
depth: node.depth,
|
|
709
|
+
supervisorName: name,
|
|
710
|
+
} : undefined;
|
|
711
|
+
},
|
|
712
|
+
subscribeSupervisorEvents: (name, listener, options) => rpcEventSource.onEvent?.(
|
|
713
|
+
name,
|
|
714
|
+
(event) => {
|
|
715
|
+
const nodeId = legionIdentity?.childNodeId(name);
|
|
716
|
+
if (nodeId) publishSupervisorEvent(legionBus, nodeId, event, options);
|
|
717
|
+
listener(event);
|
|
718
|
+
},
|
|
719
|
+
options,
|
|
720
|
+
) ?? (() => {}),
|
|
225
721
|
emitBusy: (active, label) => {
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
// when it is WAITING on subagents. Label the idle state so the
|
|
229
|
-
// sidebar says `waiting` instead of reading done. Display-layer only;
|
|
230
|
-
// the status icon stays herdr's call.
|
|
231
|
-
parentLabeler.report(active ? label : undefined);
|
|
232
|
-
try {
|
|
233
|
-
pi.events.emit(
|
|
234
|
-
"herdr:busy",
|
|
235
|
-
active ? { active: true, label } : { active: false },
|
|
236
|
-
);
|
|
237
|
-
} catch {
|
|
238
|
-
// herdr's busy overlay is optional; the TUI widget is the primary signal.
|
|
239
|
-
}
|
|
722
|
+
try { pi.events.emit("pi-legion:busy", active ? { active: true, label } : { active: false }); }
|
|
723
|
+
catch { /* optional display integration */ }
|
|
240
724
|
},
|
|
241
725
|
});
|
|
726
|
+
sessionRuntime = runtime;
|
|
242
727
|
blockedUi.set(runtime, {
|
|
243
728
|
getConfirm: () => lastConfirm,
|
|
244
729
|
notifyBlocked: (message) => {
|
|
@@ -252,69 +737,83 @@ export default function herdrSubagents(pi: ExtensionAPI) {
|
|
|
252
737
|
{ triggerTurn: true, deliverAs: "followUp" },
|
|
253
738
|
);
|
|
254
739
|
} catch {
|
|
255
|
-
// Parent session
|
|
740
|
+
// Parent session may already be gone.
|
|
256
741
|
}
|
|
257
742
|
},
|
|
258
743
|
});
|
|
259
744
|
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
//
|
|
266
|
-
//
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
/** Effective enabled state: session override > merged settings. */
|
|
271
|
-
const subagentsEnabled = (cwd: string): boolean => {
|
|
272
|
-
if (sessionEnabledOverride !== undefined) return sessionEnabledOverride;
|
|
273
|
-
try {
|
|
274
|
-
return (
|
|
275
|
-
loadSubagentSettings({
|
|
276
|
-
userSettingsPath: path.join(getAgentDir(), "settings.json"),
|
|
277
|
-
projectSettingsPath: path.join(cwd, ".pi", "settings.json"),
|
|
278
|
-
}).enabled !== false
|
|
279
|
-
);
|
|
280
|
-
} catch (error) {
|
|
281
|
-
console.warn(
|
|
282
|
-
"[pi-legion] Unable to load settings while checking whether subagents are enabled; defaulting to enabled.",
|
|
283
|
-
error,
|
|
284
|
-
);
|
|
285
|
-
return true;
|
|
286
|
-
}
|
|
287
|
-
};
|
|
288
|
-
|
|
289
|
-
registerToggleCommand(pi, {
|
|
290
|
-
sessionEnabled: () => subagentsEnabled(lastCwd),
|
|
291
|
-
setSessionEnabled: (enabled) => {
|
|
292
|
-
sessionEnabledOverride = enabled;
|
|
293
|
-
},
|
|
294
|
-
settingsEnabled: (cwd) =>
|
|
295
|
-
loadSubagentSettings({
|
|
745
|
+
if (isChild && !allowNested) {
|
|
746
|
+
pi.on("session_shutdown", shutdownRetireAll);
|
|
747
|
+
return;
|
|
748
|
+
}
|
|
749
|
+
// #17: main session only. Children (PI_SUBAGENT_ROLE / PI_SUBAGENT_CHILD)
|
|
750
|
+
// must not count their own reads or inject this reminder. Nested children
|
|
751
|
+
// also have isChild set, so they skip this even though they continue below.
|
|
752
|
+
if (!isChild) {
|
|
753
|
+
registerScoutGuard(pi, {
|
|
754
|
+
loadSettings: (cwd) => loadSubagentSettings({
|
|
296
755
|
userSettingsPath: path.join(getAgentDir(), "settings.json"),
|
|
297
756
|
projectSettingsPath: path.join(cwd, ".pi", "settings.json"),
|
|
298
|
-
})
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
});
|
|
757
|
+
}),
|
|
758
|
+
});
|
|
759
|
+
}
|
|
760
|
+
// The parent transcript records every finish; the renderer keeps a
|
|
761
|
+
// completed child to one line so visibility does not cost a screen.
|
|
762
|
+
pi.registerMessageRenderer(SUBAGENT_NOTIFY_TYPE, renderSubagentNotice);
|
|
305
763
|
|
|
306
|
-
//
|
|
307
|
-
//
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
764
|
+
// User stop controls (/legion stop, idle Esc, `x` in /legion): a thin wrapper
|
|
765
|
+
// over the subagent tool's `stop` action — graceful retire of tracked children.
|
|
766
|
+
const stopChildren = async (params: { name?: string; all?: boolean }): Promise<{ ok: boolean; text: string }> => {
|
|
767
|
+
const result = await stopAction({
|
|
768
|
+
params,
|
|
769
|
+
runtime,
|
|
770
|
+
retire: async (name) => {
|
|
771
|
+
await runtime.get(name)?.retire?.();
|
|
772
|
+
},
|
|
773
|
+
});
|
|
774
|
+
const text = result.content.map((part) => (part.type === "text" ? part.text : "")).join("\n");
|
|
775
|
+
const details = result.details as { error?: string } | undefined;
|
|
776
|
+
runtime.refreshUi();
|
|
777
|
+
return { ok: !details?.error, text };
|
|
778
|
+
};
|
|
311
779
|
const commandCatalogLoader = (input: {
|
|
312
780
|
sessionCwd: string;
|
|
313
781
|
runCwd: string;
|
|
314
782
|
scope: AgentScope | undefined;
|
|
315
783
|
}) => loadCatalog(input.sessionCwd, input.runCwd, input.scope);
|
|
316
|
-
|
|
317
|
-
|
|
784
|
+
const liveOverlayController = registerLegionCommand(pi, {
|
|
785
|
+
bus: legionBus,
|
|
786
|
+
runtime,
|
|
787
|
+
supervisor: rpcEventSource,
|
|
788
|
+
stop: stopChildren,
|
|
789
|
+
commands: {
|
|
790
|
+
team: { loadCatalog: commandCatalogLoader },
|
|
791
|
+
agents: { loadCatalog: commandCatalogLoader },
|
|
792
|
+
toggle: {
|
|
793
|
+
sessionEnabled: () => subagentsEnabled(lastCwd),
|
|
794
|
+
setSessionEnabled: (enabled) => {
|
|
795
|
+
sessionEnabledOverride = enabled;
|
|
796
|
+
},
|
|
797
|
+
settingsEnabled: (cwd) =>
|
|
798
|
+
loadSubagentSettings({
|
|
799
|
+
userSettingsPath: path.join(getAgentDir(), "settings.json"),
|
|
800
|
+
projectSettingsPath: path.join(cwd, ".pi", "settings.json"),
|
|
801
|
+
}).enabled !== false,
|
|
802
|
+
runningChildren: () =>
|
|
803
|
+
runtime
|
|
804
|
+
.activeJobs()
|
|
805
|
+
.filter((job) => job.state === "working" || job.state === "blocked")
|
|
806
|
+
.length,
|
|
807
|
+
},
|
|
808
|
+
},
|
|
809
|
+
});
|
|
810
|
+
statusNodeOpener = createStatusNodeOpener(
|
|
811
|
+
pi,
|
|
812
|
+
liveOverlayController,
|
|
813
|
+
() => lastUi,
|
|
814
|
+
() => lastCwd,
|
|
815
|
+
(ui, nodeId) => void confirmAndStopNode(ui, { runtime, stop: stopChildren }, nodeId),
|
|
816
|
+
);
|
|
318
817
|
|
|
319
818
|
pi.registerTool({
|
|
320
819
|
name: "subagent",
|
|
@@ -328,34 +827,39 @@ export default function herdrSubagents(pi: ExtensionAPI) {
|
|
|
328
827
|
async execute(
|
|
329
828
|
_id,
|
|
330
829
|
params,
|
|
331
|
-
|
|
830
|
+
signal,
|
|
332
831
|
onUpdate,
|
|
333
832
|
ctx: ExtensionContext,
|
|
334
833
|
): Promise<AgentToolResult<unknown>> {
|
|
335
834
|
runtime.bind(ctx);
|
|
835
|
+
const action = params.action ?? "launch";
|
|
836
|
+
if (signal?.aborted && action !== "stop") {
|
|
837
|
+
await cascadeRetireAll();
|
|
838
|
+
return ok("interrupted (parent turn aborted)");
|
|
839
|
+
}
|
|
336
840
|
// Master switch: refuse NEW launches with a pointer to the toggle;
|
|
337
841
|
// control actions (status/collect/wait/…) must stay available so a
|
|
338
842
|
// disable with live children remains observable and collectable.
|
|
339
|
-
const action = params.action ?? "launch";
|
|
340
843
|
if (!subagentsEnabled(ctx.cwd) && (action === "launch" || !action)) {
|
|
341
844
|
return fail(
|
|
342
|
-
"subagents are disabled (subagents.enabled: false or /
|
|
343
|
-
"Existing children are unaffected; re-enable with /
|
|
845
|
+
"subagents are disabled (subagents.enabled: false or /legion off). " +
|
|
846
|
+
"Existing children are unaffected; re-enable with /legion on.",
|
|
344
847
|
ErrorCodes.DISABLED,
|
|
345
848
|
);
|
|
346
849
|
}
|
|
347
|
-
const client = createHerdrClient();
|
|
348
|
-
if (!(await client.available())) {
|
|
349
|
-
return fail(
|
|
350
|
-
"herdr is not available. Install it (https://herdr.dev) or check HERDR_BIN.",
|
|
351
|
-
ErrorCodes.HERDR_UNAVAILABLE,
|
|
352
|
-
);
|
|
353
|
-
}
|
|
354
|
-
|
|
355
850
|
const cwd = params.cwd ?? ctx.cwd;
|
|
356
|
-
const store =
|
|
851
|
+
const store = runStoreFor(cwd);
|
|
357
852
|
const catalog = loadCatalog(ctx.cwd, cwd, params.agentScope);
|
|
358
|
-
const {
|
|
853
|
+
const { allAgents, settings } = catalog;
|
|
854
|
+
const teamName = action === "launch" ? params.team : undefined;
|
|
855
|
+
const selectedTeam = teamName
|
|
856
|
+
? applyTeam(allAgents, { ...settings, team: teamName }, { ...process.env, [TEAM_ENV]: teamName })
|
|
857
|
+
: { agents: catalog.agents, team: catalog.team, warnings: catalog.teamWarnings };
|
|
858
|
+
const agents = selectedTeam.agents;
|
|
859
|
+
const team = selectedTeam.team;
|
|
860
|
+
const teamWarnings = teamName
|
|
861
|
+
? [...selectedTeam.warnings, ...catalog.utilityWarnings]
|
|
862
|
+
: selectedTeam.warnings;
|
|
359
863
|
runtime.setJoinConfig({
|
|
360
864
|
mode: settings.joinMode ?? DEFAULT_JOIN_MODE,
|
|
361
865
|
flushMs: settings.joinFlushMs ?? DEFAULT_FLUSH_MS,
|
|
@@ -363,77 +867,133 @@ export default function herdrSubagents(pi: ExtensionAPI) {
|
|
|
363
867
|
});
|
|
364
868
|
|
|
365
869
|
if (action === "list") return ok(renderAgentList(agents, team, teamWarnings));
|
|
870
|
+
if (action === "diagnose") {
|
|
871
|
+
let settings: unknown;
|
|
872
|
+
try {
|
|
873
|
+
settings = JSON.parse(readFileSync(path.join(getAgentDir(), "settings.json"), "utf8"));
|
|
874
|
+
} catch {
|
|
875
|
+
settings = undefined;
|
|
876
|
+
}
|
|
877
|
+
const report = runLegionDoctor({
|
|
878
|
+
agentDir: getAgentDir(),
|
|
879
|
+
cwd,
|
|
880
|
+
storeRoot: store.rootDir,
|
|
881
|
+
expectedModels: expectedModelsFromSettings(settings),
|
|
882
|
+
sampleAgent: agents[0],
|
|
883
|
+
agents,
|
|
884
|
+
runtime: {
|
|
885
|
+
liveJobs: runtime.activeJobs().map((job) => ({
|
|
886
|
+
name: job.name,
|
|
887
|
+
state: job.state,
|
|
888
|
+
nodeId: job.nodeId,
|
|
889
|
+
})),
|
|
890
|
+
stopAvailable: true,
|
|
891
|
+
idleEscWired: true,
|
|
892
|
+
legionTools: ["subagent", "vexillatio"],
|
|
893
|
+
allowNestedEnv: process.env[ALLOW_NESTED_ENV] === "1",
|
|
894
|
+
},
|
|
895
|
+
});
|
|
896
|
+
return ok(report.text);
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
let activeLegion: LegionRuntimeIdentity | undefined;
|
|
900
|
+
try {
|
|
901
|
+
activeLegion = resolveLegionIdentity({ pi, supervisor, store, cwd, settings, eventBus: legionBus, cached: legionIdentity, sessionFile: ctx.sessionManager?.getSessionFile() });
|
|
902
|
+
if (!activeLegion && action === "launch" && !isChild) {
|
|
903
|
+
const plan = buildPlan(params);
|
|
904
|
+
if (plan.ok) activeLegion = createRootLegion({ pi, supervisor, store, cwd, task: plan.task, settings, maxDepth: effectiveMaxDepth(settings.legion?.maxDepth), eventBus: legionBus });
|
|
905
|
+
}
|
|
906
|
+
if (activeLegion) legionIdentity = activeLegion;
|
|
907
|
+
} catch (error) {
|
|
908
|
+
return fail(`legion runtime initialization failed: ${error instanceof Error ? error.message : String(error)}`, ErrorCodes.INVALID_PARAMS);
|
|
909
|
+
}
|
|
910
|
+
if (signal?.aborted && action !== "stop") {
|
|
911
|
+
await cascadeRetireAll();
|
|
912
|
+
return ok("interrupted (parent turn aborted)");
|
|
913
|
+
}
|
|
366
914
|
|
|
367
915
|
// Control actions address an EXISTING child; launch-family actions
|
|
368
916
|
// create new ones. The two share almost nothing, so they are separate.
|
|
369
917
|
if (action !== "launch") {
|
|
370
|
-
|
|
918
|
+
const result = await controlAction({
|
|
371
919
|
action,
|
|
372
920
|
params,
|
|
373
|
-
|
|
921
|
+
signal,
|
|
922
|
+
supervisor,
|
|
923
|
+
uiProxy,
|
|
924
|
+
onBudgetRefused,
|
|
925
|
+
verifyRunner,
|
|
374
926
|
store,
|
|
375
927
|
cwd,
|
|
376
928
|
agents,
|
|
377
929
|
allAgents,
|
|
378
930
|
team,
|
|
379
931
|
runtime,
|
|
380
|
-
layout,
|
|
381
932
|
settings,
|
|
933
|
+
legion: activeLegion,
|
|
382
934
|
});
|
|
935
|
+
if (signal?.aborted && action !== "stop") {
|
|
936
|
+
await cascadeRetireAll();
|
|
937
|
+
return ok("interrupted (parent turn aborted)");
|
|
938
|
+
}
|
|
939
|
+
return result;
|
|
383
940
|
}
|
|
384
941
|
|
|
385
|
-
|
|
942
|
+
const result = await launchFamily({
|
|
386
943
|
params,
|
|
944
|
+
signal,
|
|
387
945
|
ctx,
|
|
388
|
-
|
|
946
|
+
supervisor,
|
|
947
|
+
uiProxy,
|
|
948
|
+
onBudgetRefused,
|
|
949
|
+
verifyRunner,
|
|
389
950
|
store,
|
|
390
951
|
cwd,
|
|
391
952
|
agents,
|
|
392
953
|
allAgents,
|
|
393
954
|
settings,
|
|
394
955
|
team,
|
|
956
|
+
legion: activeLegion,
|
|
395
957
|
runtime,
|
|
396
|
-
layout,
|
|
397
958
|
onUpdate,
|
|
398
959
|
});
|
|
960
|
+
if (signal?.aborted) {
|
|
961
|
+
await cascadeRetireAll();
|
|
962
|
+
return ok("interrupted (parent turn aborted)");
|
|
963
|
+
}
|
|
964
|
+
return result;
|
|
399
965
|
},
|
|
400
966
|
});
|
|
401
967
|
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
if (event.toolName !== "bash") return;
|
|
423
|
-
// Master switch: when disabled, the user (or the model) may legitimately
|
|
424
|
-
// drive herdr by hand; the guard only makes sense while we own dispatch.
|
|
425
|
-
// Prefer the event's own ctx over lastCwd: it cannot go stale between
|
|
426
|
-
// the bindUi events that maintain lastCwd.
|
|
427
|
-
if (!subagentsEnabled(ctx?.cwd ?? lastCwd)) return;
|
|
428
|
-
const command =
|
|
429
|
-
typeof event.input.command === "string" ? event.input.command : "";
|
|
430
|
-
const reason = forbiddenDispatchReason(command);
|
|
431
|
-
if (!reason) return;
|
|
432
|
-
return { block: true, reason: blockMessage(reason) };
|
|
968
|
+
let lastCtx: ExtensionContext | undefined;
|
|
969
|
+
let escUi: ExtensionUIContext | undefined;
|
|
970
|
+
let escUnsubscribe: (() => void) | undefined;
|
|
971
|
+
const idleEscStop = createIdleEscStop({
|
|
972
|
+
isIdle: () => lastCtx?.isIdle?.() ?? false,
|
|
973
|
+
editorEmpty: () => !(lastUi?.getEditorText?.() ?? "").trim(),
|
|
974
|
+
uiBusy: () => {
|
|
975
|
+
const tui = runtime.statusTui() as { hasOverlay?: () => boolean; getFocusedComponent?: () => unknown } | undefined;
|
|
976
|
+
if (!tui) return true;
|
|
977
|
+
if (tui.hasOverlay?.()) return true;
|
|
978
|
+
const focused = tui.getFocusedComponent?.() as { getText?: unknown } | null | undefined;
|
|
979
|
+
// Only the prompt editor may have focus: dialogs/selectors keep pi's Esc.
|
|
980
|
+
return !focused || typeof focused.getText !== "function" || !("onEscape" in focused);
|
|
981
|
+
},
|
|
982
|
+
liveCount: () => liveJobs(runtime.activeJobs()).length,
|
|
983
|
+
confirm: (title, message) => lastUi ? lastUi.confirm(title, message) : Promise.resolve(false),
|
|
984
|
+
stopAll: async () => {
|
|
985
|
+
const result = await stopChildren({ all: true });
|
|
986
|
+
lastUi?.notify(result.text, result.ok ? "info" : "error");
|
|
987
|
+
},
|
|
433
988
|
});
|
|
434
|
-
|
|
435
989
|
const bindUi = (_event: unknown, ctx: ExtensionContext): void => {
|
|
436
|
-
|
|
990
|
+
lastCtx = ctx;
|
|
991
|
+
if (ctx.hasUI && ctx.ui !== escUi && typeof ctx.ui?.onTerminalInput === "function") {
|
|
992
|
+
escUnsubscribe?.();
|
|
993
|
+
escUi = ctx.ui;
|
|
994
|
+
escUnsubscribe = ctx.ui.onTerminalInput(idleEscStop);
|
|
995
|
+
}
|
|
996
|
+
lastUi = ctx.hasUI ? ctx.ui : undefined;
|
|
437
997
|
if (ctx.cwd) lastCwd = ctx.cwd;
|
|
438
998
|
runtime.bind(ctx);
|
|
439
999
|
lastConfirm =
|
|
@@ -442,7 +1002,30 @@ export default function herdrSubagents(pi: ExtensionAPI) {
|
|
|
442
1002
|
: undefined;
|
|
443
1003
|
};
|
|
444
1004
|
|
|
445
|
-
|
|
1005
|
+
uiProxy.onRequest((pending) => {
|
|
1006
|
+
const legionNodeId = legionIdentity?.childNodeId(pending.childName);
|
|
1007
|
+
if (legionNodeId) publishUIRequest(legionBus, legionNodeId, pending.request.method);
|
|
1008
|
+
const ui = lastUi;
|
|
1009
|
+
if (!ui) return;
|
|
1010
|
+
const request = pending.request;
|
|
1011
|
+
if (request.method === "confirm") return;
|
|
1012
|
+
if (request.method === "select") {
|
|
1013
|
+
void ui.select(request.title, request.options).then((value) => uiProxy.respond(pending.id, value === undefined ? { cancelled: true } : { value }));
|
|
1014
|
+
} else if (request.method === "input") {
|
|
1015
|
+
void ui.input(request.title, request.placeholder).then((value) => uiProxy.respond(pending.id, value === undefined ? { cancelled: true } : { value }));
|
|
1016
|
+
} else if (request.method === "editor") {
|
|
1017
|
+
void ui.editor(request.title, request.prefill).then((value) => uiProxy.respond(pending.id, value === undefined ? { cancelled: true } : { value }));
|
|
1018
|
+
} else if (request.method === "notify") ui.notify(request.message, request.notifyType);
|
|
1019
|
+
else if (request.method === "setStatus") ui.setStatus(request.statusKey, request.statusText);
|
|
1020
|
+
else if (request.method === "setWidget") ui.setWidget(request.widgetKey, request.widgetLines, { placement: request.widgetPlacement });
|
|
1021
|
+
else if (request.method === "setTitle") ui.setTitle(request.title);
|
|
1022
|
+
else if (request.method === "set_editor_text") ui.setEditorText(request.text);
|
|
1023
|
+
});
|
|
1024
|
+
|
|
1025
|
+
pi.on("session_start", (event, ctx) => {
|
|
1026
|
+
runtime.setParentIdle(ctx.isIdle?.() ?? true);
|
|
1027
|
+
bindUi(event, ctx);
|
|
1028
|
+
});
|
|
446
1029
|
pi.on("session_info_changed", bindUi);
|
|
447
1030
|
pi.on("turn_start", (event, ctx) => {
|
|
448
1031
|
parentTurnActive = true;
|
|
@@ -452,29 +1035,32 @@ export default function herdrSubagents(pi: ExtensionAPI) {
|
|
|
452
1035
|
parentTurnActive = false;
|
|
453
1036
|
bindUi(event, ctx);
|
|
454
1037
|
});
|
|
455
|
-
pi.on("agent_start",
|
|
456
|
-
|
|
1038
|
+
pi.on("agent_start", (event, ctx) => {
|
|
1039
|
+
runtime.setParentIdle(false);
|
|
1040
|
+
bindUi(event, ctx);
|
|
1041
|
+
});
|
|
1042
|
+
pi.on("agent_end", (event, ctx) => {
|
|
1043
|
+
runtime.setParentIdle(true);
|
|
1044
|
+
bindUi(event, ctx);
|
|
1045
|
+
});
|
|
457
1046
|
pi.on("input", bindUi);
|
|
458
1047
|
pi.on("tool_result", (_event, ctx) => {
|
|
459
1048
|
bindUi(_event, ctx);
|
|
460
1049
|
runtime.refreshUi();
|
|
461
1050
|
});
|
|
462
|
-
pi.on("session_shutdown",
|
|
463
|
-
runtime.dispose();
|
|
464
|
-
void parentLabeler.clear();
|
|
465
|
-
});
|
|
1051
|
+
pi.on("session_shutdown", shutdownRetireAll);
|
|
466
1052
|
}
|
|
467
1053
|
|
|
468
1054
|
/**
|
|
469
1055
|
* Load the effective agent catalog for a request.
|
|
470
1056
|
*
|
|
471
|
-
* User settings (`~/.pi/agent/settings.json
|
|
1057
|
+
* User settings (`~/.pi/agent/settings.json`) merge
|
|
472
1058
|
* with project `.pi/settings.json`. Agent directories are resolved from the
|
|
473
1059
|
* run's `cwd`. Bundled roles are included unless `subagents.disableBuiltins`
|
|
474
1060
|
* opts out.
|
|
475
1061
|
*
|
|
476
1062
|
* Also returns the discovery directories, for listings that tell the user
|
|
477
|
-
* WHERE the roles came from (the /
|
|
1063
|
+
* WHERE the roles came from (the /legion agents command).
|
|
478
1064
|
*/
|
|
479
1065
|
export function loadCatalog(
|
|
480
1066
|
sessionCwd: string,
|
|
@@ -488,6 +1074,7 @@ export function loadCatalog(
|
|
|
488
1074
|
teamWarnings: string[];
|
|
489
1075
|
projectAgentsDir: string | null;
|
|
490
1076
|
builtinAgentsDir: string;
|
|
1077
|
+
utilityWarnings: string[];
|
|
491
1078
|
} {
|
|
492
1079
|
const settings = loadSubagentSettings({
|
|
493
1080
|
userSettingsPath: path.join(getAgentDir(), "settings.json"),
|
|
@@ -498,6 +1085,14 @@ export function loadCatalog(
|
|
|
498
1085
|
});
|
|
499
1086
|
const overridden = applyAgentOverrides(discovery.agents, settings.agentOverrides);
|
|
500
1087
|
const teamResult = applyTeam(overridden, settings);
|
|
1088
|
+
const utilityWarnings = [...new Map(
|
|
1089
|
+
[...overridden, ...teamResult.agents]
|
|
1090
|
+
.filter((agent) => agent.utility && agent.allowNestedSubagents)
|
|
1091
|
+
.map((agent) => [
|
|
1092
|
+
agent.name,
|
|
1093
|
+
`Utility agent "${agent.name}" sets allowNestedSubagents: true; utility identity takes precedence and nested dispatch is ignored.`,
|
|
1094
|
+
] as const),
|
|
1095
|
+
).values()];
|
|
501
1096
|
const resolveDefaults = (items: AgentConfig[]): AgentConfig[] =>
|
|
502
1097
|
applyDefaultOnBlocked(
|
|
503
1098
|
applyDefaultModel(items, settings.defaultModel),
|
|
@@ -509,7 +1104,8 @@ export function loadCatalog(
|
|
|
509
1104
|
agents: resolveDefaults(teamResult.agents),
|
|
510
1105
|
allAgents: resolveDefaults(overridden),
|
|
511
1106
|
team: teamResult.team,
|
|
512
|
-
teamWarnings: teamResult.warnings,
|
|
1107
|
+
teamWarnings: [...teamResult.warnings, ...utilityWarnings],
|
|
1108
|
+
utilityWarnings,
|
|
513
1109
|
settings,
|
|
514
1110
|
projectAgentsDir: discovery.projectAgentsDir,
|
|
515
1111
|
builtinAgentsDir: discovery.builtinAgentsDir ?? BUILTIN_AGENTS_DIR,
|
|
@@ -530,14 +1126,18 @@ export function loadCatalog(
|
|
|
530
1126
|
async function controlAction(input: {
|
|
531
1127
|
action: string;
|
|
532
1128
|
params: SubagentParams;
|
|
533
|
-
|
|
1129
|
+
signal?: AbortSignal;
|
|
1130
|
+
legion?: OrchestratorLegionContext;
|
|
1131
|
+
supervisor: LegionSupervisor;
|
|
1132
|
+
uiProxy: UIProxy;
|
|
1133
|
+
onBudgetRefused: NonNullable<ConstructorParameters<typeof Orchestrator>[0]["onBudgetRefused"]>;
|
|
1134
|
+
verifyRunner?: VerifyRunner;
|
|
534
1135
|
store: RunStore;
|
|
535
1136
|
cwd: string;
|
|
536
1137
|
agents: AgentConfig[];
|
|
537
1138
|
allAgents: AgentConfig[];
|
|
538
1139
|
team: ActiveTeam;
|
|
539
1140
|
runtime: SessionRuntime;
|
|
540
|
-
layout: SessionLayout;
|
|
541
1141
|
settings: ReturnType<typeof loadSubagentSettings>;
|
|
542
1142
|
}): Promise<AgentToolResult<unknown>> {
|
|
543
1143
|
const { action, params, store, cwd } = input;
|
|
@@ -557,6 +1157,7 @@ async function controlAction(input: {
|
|
|
557
1157
|
}
|
|
558
1158
|
return waitAction({
|
|
559
1159
|
params,
|
|
1160
|
+
signal: input.signal,
|
|
560
1161
|
runtime: input.runtime,
|
|
561
1162
|
agents: input.agents,
|
|
562
1163
|
allAgents: input.allAgents,
|
|
@@ -565,6 +1166,40 @@ async function controlAction(input: {
|
|
|
565
1166
|
});
|
|
566
1167
|
}
|
|
567
1168
|
|
|
1169
|
+
if (action === "stop") {
|
|
1170
|
+
return stopAction({
|
|
1171
|
+
params,
|
|
1172
|
+
runtime: input.runtime,
|
|
1173
|
+
retire: async (name) => {
|
|
1174
|
+
const job = input.runtime.get(name);
|
|
1175
|
+
if (job?.retire) {
|
|
1176
|
+
await job.retire();
|
|
1177
|
+
return;
|
|
1178
|
+
}
|
|
1179
|
+
// A completion watch can retire/release a target after stop snapshots
|
|
1180
|
+
// all names but before the serial loop reaches it. Treat that target as
|
|
1181
|
+
// stopped; only rebuild if its persisted record is not already retired.
|
|
1182
|
+
const found = findChild(input.store.listRuns(), name);
|
|
1183
|
+
if (!found) throw new SubagentError(`unknown child: ${name}`, ErrorCodes.NOT_FOUND);
|
|
1184
|
+
if (found.child.state === "retired") return;
|
|
1185
|
+
const orchestrator = new Orchestrator({
|
|
1186
|
+
supervisor: input.supervisor,
|
|
1187
|
+
uiProxy: input.uiProxy,
|
|
1188
|
+
runDir: input.store.runDir(found.runId),
|
|
1189
|
+
cwd: input.cwd,
|
|
1190
|
+
maxDepth: input.settings.legion?.maxDepth,
|
|
1191
|
+
childContext: input.settings.childContext,
|
|
1192
|
+
...teamForChildren(input.team),
|
|
1193
|
+
...(input.legion ? { legion: { ...input.legion, runId: found.runId, runStore: input.store } } : {}),
|
|
1194
|
+
onBudgetRefused: input.onBudgetRefused,
|
|
1195
|
+
});
|
|
1196
|
+
orchestrator.restore(found.run);
|
|
1197
|
+
await orchestrator.retire(name);
|
|
1198
|
+
await persistChild(input.store, found.runId, orchestrator, name);
|
|
1199
|
+
},
|
|
1200
|
+
});
|
|
1201
|
+
}
|
|
1202
|
+
|
|
568
1203
|
if (!params.name) {
|
|
569
1204
|
return fail(
|
|
570
1205
|
`\`name\` is required for the "${action}" action.`,
|
|
@@ -576,14 +1211,18 @@ async function controlAction(input: {
|
|
|
576
1211
|
if (!found) return unknownChild(params.name, store, cwd);
|
|
577
1212
|
|
|
578
1213
|
const orchestrator = new Orchestrator({
|
|
579
|
-
|
|
1214
|
+
supervisor: input.supervisor,
|
|
1215
|
+
uiProxy: input.uiProxy,
|
|
580
1216
|
runDir: store.runDir(found.runId),
|
|
581
1217
|
cwd,
|
|
582
|
-
|
|
583
|
-
workspaceId: process.env.HERDR_WORKSPACE_ID,
|
|
584
|
-
parentPaneId: process.env.HERDR_PANE_ID,
|
|
1218
|
+
maxDepth: input.settings.legion?.maxDepth,
|
|
585
1219
|
childContext: input.settings.childContext,
|
|
1220
|
+
verifyRunner: input.verifyRunner,
|
|
1221
|
+
worktreeBootstrap: input.settings.legion?.worktreeBootstrap,
|
|
1222
|
+
worktreeBootstrapTimeoutMs: input.settings.legion?.worktreeBootstrapTimeoutMs,
|
|
586
1223
|
...teamForChildren(input.team),
|
|
1224
|
+
...(input.legion ? { legion: { ...input.legion, runId: found.runId, runStore: store } } : {}),
|
|
1225
|
+
onBudgetRefused: input.onBudgetRefused,
|
|
587
1226
|
});
|
|
588
1227
|
orchestrator.restore(found.run);
|
|
589
1228
|
|
|
@@ -594,9 +1233,20 @@ async function controlAction(input: {
|
|
|
594
1233
|
orchestrator,
|
|
595
1234
|
};
|
|
596
1235
|
|
|
597
|
-
if (action === "status")
|
|
1236
|
+
if (action === "status") {
|
|
1237
|
+
return ok(renderLiveChildStatus({
|
|
1238
|
+
name: found.child.name,
|
|
1239
|
+
state: found.child.state,
|
|
1240
|
+
agent: found.child.agent,
|
|
1241
|
+
kind: found.child.kind,
|
|
1242
|
+
sessionFile: found.child.sessionFile,
|
|
1243
|
+
execution: found.child.execution,
|
|
1244
|
+
acceptance: found.child.acceptance,
|
|
1245
|
+
alive: input.supervisor.isAlive(params.name),
|
|
1246
|
+
}));
|
|
1247
|
+
}
|
|
598
1248
|
if (action === "steer") return steerChild(ctx);
|
|
599
|
-
if (action === "collect") return collectChild(ctx);
|
|
1249
|
+
if (action === "collect") return collectChild(ctx, input.signal);
|
|
600
1250
|
if (action === "retire") return retireChild(ctx);
|
|
601
1251
|
if (action === "continue" || action === "resume") return reviveChild(ctx);
|
|
602
1252
|
|
|
@@ -609,7 +1259,9 @@ interface ChildContext {
|
|
|
609
1259
|
params: SubagentParams;
|
|
610
1260
|
/** Always set: `controlAction` rejects a request without a name. */
|
|
611
1261
|
name: string;
|
|
612
|
-
|
|
1262
|
+
supervisor: LegionSupervisor;
|
|
1263
|
+
uiProxy: UIProxy;
|
|
1264
|
+
onBudgetRefused: NonNullable<ConstructorParameters<typeof Orchestrator>[0]["onBudgetRefused"]>;
|
|
613
1265
|
store: RunStore;
|
|
614
1266
|
cwd: string;
|
|
615
1267
|
agents: AgentConfig[];
|
|
@@ -631,14 +1283,7 @@ function unknownChild(
|
|
|
631
1283
|
store: RunStore,
|
|
632
1284
|
cwd: string,
|
|
633
1285
|
): AgentToolResult<unknown> {
|
|
634
|
-
const known = store
|
|
635
|
-
.listRuns()
|
|
636
|
-
.filter((r) => {
|
|
637
|
-
const owner = process.env.HERDR_PANE_ID?.trim();
|
|
638
|
-
if (!owner) return true;
|
|
639
|
-
return r.herdr.parentPaneId === owner;
|
|
640
|
-
})
|
|
641
|
-
.flatMap((r) => r.children.map((c) => c.name));
|
|
1286
|
+
const known = store.listRuns().flatMap((run) => run.children.map((child) => child.name));
|
|
642
1287
|
return fail(
|
|
643
1288
|
`unknown child: ${name} (no run under ${cwd}/.pi-subagents` +
|
|
644
1289
|
`${known.length ? `; known here: ${known.join(", ")}` : ""}). ` +
|
|
@@ -667,12 +1312,17 @@ async function steerChild(
|
|
|
667
1312
|
/** Wait for the child's current turn and derive its outcome from the session. */
|
|
668
1313
|
async function collectChild(
|
|
669
1314
|
ctx: ChildContext,
|
|
1315
|
+
signal?: AbortSignal,
|
|
670
1316
|
): Promise<AgentToolResult<unknown>> {
|
|
671
1317
|
try {
|
|
672
|
-
|
|
1318
|
+
if (signal?.aborted) return ok("interrupted (parent turn aborted)");
|
|
1319
|
+
const pending = signal
|
|
1320
|
+
? ctx.runtime.consumeCollectInterruptible(ctx.name, signal)
|
|
1321
|
+
: ctx.runtime.consumeCollect(ctx.name);
|
|
673
1322
|
if (pending) {
|
|
674
1323
|
const collected = await pending;
|
|
675
|
-
|
|
1324
|
+
if (!collected || signal?.aborted) return ok("interrupted (parent turn aborted)");
|
|
1325
|
+
await persistChild(ctx.store, ctx.found.runId, ctx.orchestrator, ctx.name, { preserveRetired: true });
|
|
676
1326
|
return ok(renderCollect(ctx.name, collected));
|
|
677
1327
|
}
|
|
678
1328
|
|
|
@@ -685,12 +1335,16 @@ async function collectChild(
|
|
|
685
1335
|
ctx.found.child.agent,
|
|
686
1336
|
);
|
|
687
1337
|
const timeoutMs = agentDef?.timeoutMs ?? DEFAULTS.turnTimeoutMs;
|
|
688
|
-
const collected = await ctx.orchestrator.collect(ctx.name, {
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
1338
|
+
const collected = await ctx.orchestrator.collect(ctx.name, {
|
|
1339
|
+
timeoutMs,
|
|
1340
|
+
...(signal ? { signal } : {}),
|
|
1341
|
+
});
|
|
1342
|
+
if (signal?.aborted || collected.execution.reason === "aborted by parent") {
|
|
1343
|
+
return ok("interrupted (parent turn aborted)");
|
|
1344
|
+
}
|
|
1345
|
+
const retireAlreadyFinishedJob = shouldRecycleAfterCollect(collected.execution.status) && !collected.blocked;
|
|
1346
|
+
await persistChild(ctx.store, ctx.found.runId, ctx.orchestrator, ctx.name, { preserveRetired: !retireAlreadyFinishedJob });
|
|
1347
|
+
if (retireAlreadyFinishedJob) {
|
|
694
1348
|
await ctx.orchestrator.retire(ctx.name);
|
|
695
1349
|
await persistChild(ctx.store, ctx.found.runId, ctx.orchestrator, ctx.name);
|
|
696
1350
|
}
|
|
@@ -701,7 +1355,7 @@ async function collectChild(
|
|
|
701
1355
|
}
|
|
702
1356
|
}
|
|
703
1357
|
|
|
704
|
-
/**
|
|
1358
|
+
/** Retire the RPC child while preserving its outcome and session for resume. */
|
|
705
1359
|
async function retireChild(
|
|
706
1360
|
ctx: ChildContext,
|
|
707
1361
|
): Promise<AgentToolResult<unknown>> {
|
|
@@ -718,7 +1372,7 @@ async function retireChild(
|
|
|
718
1372
|
await persistChild(ctx.store, ctx.found.runId, ctx.orchestrator, ctx.name);
|
|
719
1373
|
return ok(
|
|
720
1374
|
`Retired ${ctx.name} (execution=${child.execution?.status ?? "unknown"}). ` +
|
|
721
|
-
`
|
|
1375
|
+
`RPC process stopped; session kept for resume: ${child.sessionFile}`,
|
|
722
1376
|
);
|
|
723
1377
|
} catch (error) {
|
|
724
1378
|
return fail(`retire failed: ${String(error)}`, ErrorCodes.RETIRE_FAILED);
|
|
@@ -729,12 +1383,12 @@ async function retireChild(
|
|
|
729
1383
|
* Continue or resume a child.
|
|
730
1384
|
*
|
|
731
1385
|
* A live agent is prompted in place; an exited one is relaunched from its
|
|
732
|
-
* persisted session so its context survives the
|
|
1386
|
+
* persisted session so its context survives the process being gone (F12).
|
|
733
1387
|
*/
|
|
734
1388
|
async function reviveChild(
|
|
735
1389
|
ctx: ChildContext,
|
|
736
1390
|
): Promise<AgentToolResult<unknown>> {
|
|
737
|
-
const { action, params,
|
|
1391
|
+
const { action, params, supervisor, name, found, orchestrator } = ctx;
|
|
738
1392
|
|
|
739
1393
|
if (!params.message) {
|
|
740
1394
|
return fail(
|
|
@@ -756,23 +1410,11 @@ async function reviveChild(
|
|
|
756
1410
|
}
|
|
757
1411
|
|
|
758
1412
|
try {
|
|
759
|
-
|
|
760
|
-
if (agentState.ok) {
|
|
1413
|
+
if (supervisor.isAlive(name)) {
|
|
761
1414
|
await orchestrator.steer(name, params.message);
|
|
762
1415
|
followChild(ctx, { watch: true });
|
|
763
1416
|
return ok(
|
|
764
|
-
`${action === "resume" ? "Resumed" : "Continued"} ${name} (live
|
|
765
|
-
);
|
|
766
|
-
}
|
|
767
|
-
// Only a DEFINITIVE not-found means the agent exited and must be
|
|
768
|
-
// relaunched. Any other failure (a wedged or timed-out herdr CLI) says
|
|
769
|
-
// nothing about the child — relaunching now would collide with the live
|
|
770
|
-
// agent holding the name (F16) or spawn a duplicate.
|
|
771
|
-
if (agentState.error.code !== ErrorCodes.NOT_FOUND) {
|
|
772
|
-
return fail(
|
|
773
|
-
`${action} failed: cannot determine whether ${name} is alive ` +
|
|
774
|
-
`(herdr: ${agentState.error.message}). Retry shortly.`,
|
|
775
|
-
agentState.error.code,
|
|
1417
|
+
`${action === "resume" ? "Resumed" : "Continued"} ${name} (live RPC child steered).`,
|
|
776
1418
|
);
|
|
777
1419
|
}
|
|
778
1420
|
|
|
@@ -784,8 +1426,7 @@ async function reviveChild(
|
|
|
784
1426
|
});
|
|
785
1427
|
followChild(ctx, { watch: true });
|
|
786
1428
|
return ok(
|
|
787
|
-
`Resumed ${handle.name} from its session
|
|
788
|
-
`Context preserved from ${handle.sessionFile}`,
|
|
1429
|
+
`Resumed ${handle.name} from its session. Context preserved from ${handle.sessionFile}`,
|
|
789
1430
|
);
|
|
790
1431
|
} catch (error) {
|
|
791
1432
|
return fail(`${action} failed: ${String(error)}`, ErrorCodes.START_FAILED);
|
|
@@ -800,19 +1441,22 @@ async function reviveChild(
|
|
|
800
1441
|
async function launchFamily(input: {
|
|
801
1442
|
params: SubagentParams;
|
|
802
1443
|
ctx: ExtensionContext;
|
|
803
|
-
|
|
1444
|
+
supervisor: LegionSupervisor;
|
|
1445
|
+
uiProxy: UIProxy;
|
|
1446
|
+
onBudgetRefused: NonNullable<ConstructorParameters<typeof Orchestrator>[0]["onBudgetRefused"]>;
|
|
1447
|
+
verifyRunner?: VerifyRunner;
|
|
804
1448
|
store: RunStore;
|
|
805
1449
|
cwd: string;
|
|
806
1450
|
agents: AgentConfig[];
|
|
807
1451
|
allAgents: AgentConfig[];
|
|
808
1452
|
settings: ReturnType<typeof resolveSubagentSettings>;
|
|
809
1453
|
team: ActiveTeam;
|
|
1454
|
+
legion?: LegionRuntimeIdentity;
|
|
810
1455
|
runtime: SessionRuntime;
|
|
811
|
-
|
|
1456
|
+
signal?: AbortSignal;
|
|
812
1457
|
onUpdate?: AgentToolUpdateCallback;
|
|
813
1458
|
}): Promise<AgentToolResult<unknown>> {
|
|
814
|
-
const { params, ctx, store, cwd, agents, settings, runtime,
|
|
815
|
-
input;
|
|
1459
|
+
const { params, ctx, store, cwd, agents, settings, runtime, onUpdate } = input;
|
|
816
1460
|
|
|
817
1461
|
const plan = buildPlan(params);
|
|
818
1462
|
if (!plan.ok) return fail(plan.message, ErrorCodes.INVALID_PARAMS);
|
|
@@ -821,40 +1465,65 @@ async function launchFamily(input: {
|
|
|
821
1465
|
content: [
|
|
822
1466
|
{
|
|
823
1467
|
type: "text",
|
|
824
|
-
text: `playbook: ${plan.steps.map((s) => s.agent).join(", ")} →
|
|
1468
|
+
text: `playbook: ${plan.steps.map((s) => s.agent).join(", ")} → RPC launch → watch`,
|
|
825
1469
|
},
|
|
826
1470
|
],
|
|
827
1471
|
details: {},
|
|
828
1472
|
});
|
|
829
1473
|
|
|
830
|
-
const
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
1474
|
+
const maxDepth = effectiveMaxDepth(settings.legion?.maxDepth);
|
|
1475
|
+
const legion = input.legion;
|
|
1476
|
+
let run = legion ? store.readRun(legion.runId) : null;
|
|
1477
|
+
if (!run) {
|
|
1478
|
+
run = store.createRun({
|
|
1479
|
+
task: plan.task,
|
|
1480
|
+
cwd,
|
|
1481
|
+
maxDepth,
|
|
1482
|
+
...(legion ? { path: parseNestedPathEnv(process.env.PI_SUBAGENT_PARENT_PATH), nodeId: legion.nodeId, herdr: { supervisor: "rpc" as const, node_id: legion.nodeId } } : { herdr: { supervisor: "rpc" as const } }),
|
|
1483
|
+
});
|
|
1484
|
+
if (legion && legion.nodeId !== "root" && legion.parentRunId) {
|
|
1485
|
+
const node = getNode(legion.db, legion.nodeId);
|
|
1486
|
+
if (node?.sessionFile) {
|
|
1487
|
+
const parentRun = store.readRun(legion.parentRunId);
|
|
1488
|
+
if (parentRun && !parentRun.children.some((child) => child.nodeId === node.id)) {
|
|
1489
|
+
await store.addChild(legion.parentRunId, {
|
|
1490
|
+
name: node.name,
|
|
1491
|
+
nodeId: node.id,
|
|
1492
|
+
sessionFile: node.sessionFile,
|
|
1493
|
+
ownerToken: node.id,
|
|
1494
|
+
state: "working",
|
|
1495
|
+
spawnedAt: run.createdAt,
|
|
1496
|
+
agent: node.role,
|
|
1497
|
+
kind: node.kind as AgentConfig["kind"],
|
|
1498
|
+
...(node.model ? { model: node.model } : {}),
|
|
1499
|
+
...(node.worktreePath ? { worktreePath: node.worktreePath } : {}),
|
|
1500
|
+
});
|
|
1501
|
+
}
|
|
1502
|
+
}
|
|
1503
|
+
updateLegionRunId(legion.db, legion.nodeId, run.runId);
|
|
1504
|
+
}
|
|
1505
|
+
}
|
|
842
1506
|
const orchestrator = new Orchestrator({
|
|
843
|
-
|
|
844
|
-
|
|
1507
|
+
supervisor: input.supervisor,
|
|
1508
|
+
uiProxy: input.uiProxy,
|
|
1509
|
+
runDir: legion?.runDir ?? store.runDir(run.runId),
|
|
845
1510
|
cwd,
|
|
846
|
-
|
|
847
|
-
// Enforce the session spawn budget so a runaway fan-out cannot exhaust
|
|
848
|
-
// the machine (ErrorCodes.BUDGET_EXCEEDED).
|
|
1511
|
+
maxDepth: settings.legion?.maxDepth,
|
|
849
1512
|
maxSpawns: settings.maxSubagentSpawnsPerSession ?? null,
|
|
850
|
-
workspaceId: process.env.HERDR_WORKSPACE_ID,
|
|
851
|
-
parentPaneId: process.env.HERDR_PANE_ID,
|
|
852
1513
|
childContext: settings.childContext,
|
|
1514
|
+
verifyRunner: input.verifyRunner,
|
|
1515
|
+
worktreeBootstrap: settings.legion?.worktreeBootstrap,
|
|
1516
|
+
worktreeBootstrapTimeoutMs: settings.legion?.worktreeBootstrapTimeoutMs,
|
|
853
1517
|
...teamForChildren(input.team),
|
|
1518
|
+
...(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 } } : {}),
|
|
1519
|
+
onBudgetRefused: input.onBudgetRefused,
|
|
854
1520
|
});
|
|
1521
|
+
orchestrator.restore(run);
|
|
855
1522
|
|
|
856
1523
|
const session: LaunchSession = {
|
|
857
1524
|
params,
|
|
1525
|
+
signal: input.signal,
|
|
1526
|
+
teamOverride: params.team,
|
|
858
1527
|
store,
|
|
859
1528
|
agents,
|
|
860
1529
|
allAgents: input.allAgents,
|
|
@@ -872,13 +1541,27 @@ async function launchFamily(input: {
|
|
|
872
1541
|
timeoutByName: new Map(),
|
|
873
1542
|
};
|
|
874
1543
|
|
|
1544
|
+
const interruptedResult = async (): Promise<AgentToolResult<unknown> | undefined> => {
|
|
1545
|
+
if (!input.signal?.aborted) return undefined;
|
|
1546
|
+
for (const name of session.handles) await abandonLaunchedChild(session, name);
|
|
1547
|
+
session.handles.length = 0;
|
|
1548
|
+
return {
|
|
1549
|
+
content: [{ type: "text", text: session.results.join("\n") || "interrupted (parent turn aborted)" }],
|
|
1550
|
+
details: { runId: run.runId, handles: [], async: params.async ?? true },
|
|
1551
|
+
};
|
|
1552
|
+
};
|
|
1553
|
+
|
|
875
1554
|
// Every planned child is independent and launches concurrently.
|
|
876
1555
|
await Promise.all(plan.steps.map((step) => launchStep(session, step)));
|
|
1556
|
+
const interruptedLaunch = await interruptedResult();
|
|
1557
|
+
if (interruptedLaunch) return interruptedLaunch;
|
|
877
1558
|
|
|
878
1559
|
// Persist children first so a crash during the wait still leaves a record.
|
|
879
1560
|
for (const name of session.handles) {
|
|
880
1561
|
await persistChild(store, run.runId, orchestrator, name);
|
|
881
1562
|
}
|
|
1563
|
+
const interruptedPersistence = await interruptedResult();
|
|
1564
|
+
if (interruptedPersistence) return interruptedPersistence;
|
|
882
1565
|
|
|
883
1566
|
// Collect inline unless the caller asked for async (the default).
|
|
884
1567
|
// Async: the session runtime watches each child and wakes the parent
|
|
@@ -888,11 +1571,16 @@ async function launchFamily(input: {
|
|
|
888
1571
|
followLaunched(session, name, { watch: isAsync });
|
|
889
1572
|
}
|
|
890
1573
|
if (!isAsync) {
|
|
891
|
-
await collectInline(session);
|
|
1574
|
+
await collectInline(session, input.signal);
|
|
892
1575
|
for (const name of session.handles) {
|
|
893
1576
|
const child = orchestrator
|
|
894
1577
|
.childrenSnapshot()
|
|
895
1578
|
.find((c) => c.name === name);
|
|
1579
|
+
if (input.signal?.aborted && (!child?.execution || !shouldRecycleAfterCollect(child.execution.status))) {
|
|
1580
|
+
followLaunched(session, name, { watch: true });
|
|
1581
|
+
await persistChild(store, run.runId, orchestrator, name);
|
|
1582
|
+
continue;
|
|
1583
|
+
}
|
|
896
1584
|
if (
|
|
897
1585
|
child?.execution &&
|
|
898
1586
|
shouldRecycleAfterCollect(child.execution.status)
|
|
@@ -904,19 +1592,9 @@ async function launchFamily(input: {
|
|
|
904
1592
|
}
|
|
905
1593
|
}
|
|
906
1594
|
|
|
907
|
-
// Record the type-tab the children joined so a later process can see it.
|
|
908
|
-
if (orchestrator.tabId) {
|
|
909
|
-
await store.updateRun(run.runId, (r) => {
|
|
910
|
-
r.herdr = {
|
|
911
|
-
...r.herdr,
|
|
912
|
-
tabId: orchestrator.tabId ?? undefined,
|
|
913
|
-
};
|
|
914
|
-
});
|
|
915
|
-
}
|
|
916
|
-
|
|
917
1595
|
if (isAsync && session.handles.length > 0) {
|
|
918
1596
|
session.results.push(
|
|
919
|
-
"↳ async: a completion message is queued when each child finishes.
|
|
1597
|
+
"↳ async: a completion message is queued when each RPC child finishes. It wakes this session when idle; otherwise it is delivered after the current turn. Use wait or collect for results. Do not tell children to message the parent.",
|
|
920
1598
|
);
|
|
921
1599
|
}
|
|
922
1600
|
|
|
@@ -929,6 +1607,8 @@ async function launchFamily(input: {
|
|
|
929
1607
|
/** Accumulated state for one launch-family request. */
|
|
930
1608
|
interface LaunchSession {
|
|
931
1609
|
params: SubagentParams;
|
|
1610
|
+
signal?: AbortSignal;
|
|
1611
|
+
teamOverride?: string;
|
|
932
1612
|
store: RunStore;
|
|
933
1613
|
agents: AgentConfig[];
|
|
934
1614
|
allAgents: AgentConfig[];
|
|
@@ -950,8 +1630,9 @@ interface LaunchSession {
|
|
|
950
1630
|
*/
|
|
951
1631
|
async function launchStep(
|
|
952
1632
|
session: LaunchSession,
|
|
953
|
-
step: { agent: string; task: string; model?: string; preset?: string; worktree?: boolean },
|
|
1633
|
+
step: { agent: string; task: string; model?: string; preset?: string; worktree?: boolean; verify?: string[] },
|
|
954
1634
|
): Promise<void> {
|
|
1635
|
+
if (session.signal?.aborted) return;
|
|
955
1636
|
const agent = findAgent(session.agents, step.agent);
|
|
956
1637
|
if (!agent) {
|
|
957
1638
|
session.results.push(
|
|
@@ -981,6 +1662,34 @@ async function launchStep(
|
|
|
981
1662
|
const effective = model.agent;
|
|
982
1663
|
const resolved = model.resolved;
|
|
983
1664
|
|
|
1665
|
+
// `verify:` on the launch call becomes a criterion with an explicit
|
|
1666
|
+
// `command`, so `applyVerification` runs it in the child worktree at
|
|
1667
|
+
// collect time — the extension verifies the child's verdict instead
|
|
1668
|
+
// of trusting pasted test output. Blank entries are dropped; an empty
|
|
1669
|
+
// result is a no-op. Never mutated onto the shared agent object: the
|
|
1670
|
+
// catalog would hand the same reference to every later launch.
|
|
1671
|
+
const verifyCommands = (step.verify ?? session.params.verify ?? [])
|
|
1672
|
+
.map((command) => command.trim())
|
|
1673
|
+
.filter(Boolean);
|
|
1674
|
+
const launchedAgent = verifyCommands.length
|
|
1675
|
+
? {
|
|
1676
|
+
...effective,
|
|
1677
|
+
acceptance: {
|
|
1678
|
+
...(effective.acceptance ?? { level: "attested" as const }),
|
|
1679
|
+
criteria: [
|
|
1680
|
+
...(effective.acceptance?.criteria ?? []),
|
|
1681
|
+
{
|
|
1682
|
+
id: "task-verify",
|
|
1683
|
+
must: "Launcher-supplied verify commands pass (run by the extension, not the child)",
|
|
1684
|
+
evidence: ["verification-output"],
|
|
1685
|
+
severity: "required" as const,
|
|
1686
|
+
command: verifyCommands.join(" && "),
|
|
1687
|
+
},
|
|
1688
|
+
],
|
|
1689
|
+
},
|
|
1690
|
+
}
|
|
1691
|
+
: effective;
|
|
1692
|
+
|
|
984
1693
|
const violation = checkModelScope(
|
|
985
1694
|
resolved.model,
|
|
986
1695
|
session.settings.modelScope,
|
|
@@ -991,31 +1700,39 @@ async function launchStep(
|
|
|
991
1700
|
return;
|
|
992
1701
|
}
|
|
993
1702
|
|
|
994
|
-
// Let `launch()` allocate
|
|
995
|
-
//
|
|
996
|
-
// collide with another session's agent.
|
|
1703
|
+
// Let `launch()` allocate from the supervisor-local registry; the index
|
|
1704
|
+
// must not race separate subagent tool calls.
|
|
997
1705
|
const handle = await session.orchestrator.launch({
|
|
998
|
-
agent:
|
|
1706
|
+
agent: launchedAgent,
|
|
999
1707
|
task: step.task,
|
|
1708
|
+
...(session.teamOverride ? { team: session.teamOverride } : {}),
|
|
1000
1709
|
...(resolved.model ? { model: resolved.model } : {}),
|
|
1001
1710
|
// The origin decides whether an unacceptable model is refused or
|
|
1002
1711
|
// dropped, and only this layer can tell them apart (a parent model
|
|
1003
1712
|
// arrives as the same `dispatch` source as a per-run override).
|
|
1004
1713
|
modelOrigin: model.modelOrigin,
|
|
1005
|
-
...(model.placement ? { placement: model.placement } : {}),
|
|
1006
1714
|
worktree: resolveLaunchWorktree({
|
|
1007
1715
|
roleDefault: effective.worktree,
|
|
1008
1716
|
launch: session.params.worktree,
|
|
1009
1717
|
step: step.worktree,
|
|
1010
1718
|
}),
|
|
1011
1719
|
});
|
|
1012
|
-
|
|
1720
|
+
const recorded = session.store.findChild(session.runId, handle.name);
|
|
1721
|
+
if (recorded?.nodeId && recorded.nodeId === handle.child.nodeId) {
|
|
1722
|
+
await session.store.updateChild(session.runId, handle.name, (child) => Object.assign(child, handle.child));
|
|
1723
|
+
} else {
|
|
1724
|
+
await session.store.addChild(session.runId, handle.child);
|
|
1725
|
+
}
|
|
1013
1726
|
session.handles.push(handle.name);
|
|
1014
1727
|
if (effective.timeoutMs !== undefined) {
|
|
1015
1728
|
session.timeoutByName.set(handle.name, effective.timeoutMs);
|
|
1016
1729
|
}
|
|
1730
|
+
if (session.signal?.aborted) {
|
|
1731
|
+
await abandonLaunchedChild(session, handle.name);
|
|
1732
|
+
return;
|
|
1733
|
+
}
|
|
1017
1734
|
session.results.push(
|
|
1018
|
-
`▶ ${handle.name} (${effective.name})
|
|
1735
|
+
`▶ ${handle.name} (${effective.name}) supervisor=rpc`,
|
|
1019
1736
|
);
|
|
1020
1737
|
// An inherited model the target CLI cannot express is dropped on purpose
|
|
1021
1738
|
// (the parent's pi model means nothing to cursor). Say so, or the user
|
|
@@ -1031,7 +1748,7 @@ async function launchStep(
|
|
|
1031
1748
|
content: [
|
|
1032
1749
|
{
|
|
1033
1750
|
type: "text",
|
|
1034
|
-
text: `playbook: ${handle.name} started
|
|
1751
|
+
text: `playbook: ${handle.name} started via RPC`,
|
|
1035
1752
|
},
|
|
1036
1753
|
],
|
|
1037
1754
|
details: {},
|
|
@@ -1068,7 +1785,7 @@ export function unknownAgentLine(
|
|
|
1068
1785
|
): string {
|
|
1069
1786
|
const available = agents.map((a) => a.name).join(", ") || "none";
|
|
1070
1787
|
if (teamName) {
|
|
1071
|
-
return `✗ unknown agent "${requested}" in team "${teamName}". Available: ${available}. Switch with /
|
|
1788
|
+
return `✗ unknown agent "${requested}" in team "${teamName}". Available: ${available}. Switch with /legion team use ${teamName} (or "default").`;
|
|
1072
1789
|
}
|
|
1073
1790
|
return `✗ unknown agent "${requested}". Available: ${available}`;
|
|
1074
1791
|
}
|
|
@@ -1093,7 +1810,7 @@ export function unknownAgentLineForCatalog(
|
|
|
1093
1810
|
}
|
|
1094
1811
|
|
|
1095
1812
|
/**
|
|
1096
|
-
* Resolve the model
|
|
1813
|
+
* Resolve the model one step will launch with.
|
|
1097
1814
|
*
|
|
1098
1815
|
* The precedence logic itself lives in `resolveStepModel`
|
|
1099
1816
|
* (`src/agents/step-model.ts`), which is pure and unit-tested; this wrapper
|
|
@@ -1107,7 +1824,6 @@ function resolveStep(
|
|
|
1107
1824
|
step: { model?: string; preset?: string },
|
|
1108
1825
|
): {
|
|
1109
1826
|
resolved: ReturnType<typeof resolveModel>;
|
|
1110
|
-
placement?: Placement;
|
|
1111
1827
|
agent: AgentConfig;
|
|
1112
1828
|
modelOrigin: ModelOrigin;
|
|
1113
1829
|
} {
|
|
@@ -1121,19 +1837,27 @@ function resolveStep(
|
|
|
1121
1837
|
});
|
|
1122
1838
|
return {
|
|
1123
1839
|
resolved: result.resolved,
|
|
1124
|
-
...(params.placement ? { placement: params.placement as Placement } : {}),
|
|
1125
1840
|
agent: result.agent,
|
|
1126
1841
|
modelOrigin: result.modelOrigin,
|
|
1127
1842
|
};
|
|
1128
1843
|
}
|
|
1129
1844
|
|
|
1130
1845
|
/** Wait for each launched child's turn, appending its outcome. */
|
|
1131
|
-
async function collectInline(session: LaunchSession): Promise<void> {
|
|
1846
|
+
async function collectInline(session: LaunchSession, signal?: AbortSignal): Promise<void> {
|
|
1132
1847
|
for (const name of session.handles) {
|
|
1848
|
+
if (signal?.aborted) {
|
|
1849
|
+
session.results.push("interrupted (parent turn aborted)");
|
|
1850
|
+
return;
|
|
1851
|
+
}
|
|
1133
1852
|
try {
|
|
1134
1853
|
const collected = await session.orchestrator.collect(name, {
|
|
1135
1854
|
timeoutMs: session.timeoutByName.get(name) ?? DEFAULTS.turnTimeoutMs,
|
|
1855
|
+
...(signal ? { signal } : {}),
|
|
1136
1856
|
});
|
|
1857
|
+
if (signal?.aborted || collected.execution.reason === "aborted by parent") {
|
|
1858
|
+
session.results.push("interrupted (parent turn aborted)");
|
|
1859
|
+
return;
|
|
1860
|
+
}
|
|
1137
1861
|
session.results.push(renderCollect(name, collected));
|
|
1138
1862
|
} catch (error) {
|
|
1139
1863
|
session.results.push(`✗ ${name}: collect failed: ${String(error)}`);
|
|
@@ -1159,6 +1883,334 @@ type Plan =
|
|
|
1159
1883
|
}
|
|
1160
1884
|
| { ok: false; message: string };
|
|
1161
1885
|
|
|
1886
|
+
type UtilityTaskItem = Static<typeof TaskItem>;
|
|
1887
|
+
type VexillatioParams = Static<typeof VexillatioParams>;
|
|
1888
|
+
|
|
1889
|
+
type UtilityPlan =
|
|
1890
|
+
| { ok: true; steps: UtilityTaskItem[] }
|
|
1891
|
+
| { ok: false; message: string };
|
|
1892
|
+
|
|
1893
|
+
function buildUtilityPlan(params: VexillatioParams): UtilityPlan {
|
|
1894
|
+
if (!params.task.trim()) return { ok: false, message: "`task` must not be blank." };
|
|
1895
|
+
const steps: UtilityTaskItem[] = params.tasks?.length
|
|
1896
|
+
? params.tasks
|
|
1897
|
+
: [{ agent: params.agent?.trim() || "scout", task: params.task }];
|
|
1898
|
+
const unsupportedVerify = steps.findIndex((item) => item.verify !== undefined);
|
|
1899
|
+
if (unsupportedVerify >= 0) {
|
|
1900
|
+
return {
|
|
1901
|
+
ok: false,
|
|
1902
|
+
message: `tasks[] step ${unsupportedVerify + 1} cannot include \`verify\`; vexillatio does not run verification commands.`,
|
|
1903
|
+
};
|
|
1904
|
+
}
|
|
1905
|
+
const invalid = steps.findIndex((item) => !item.agent.trim() || !item.task.trim());
|
|
1906
|
+
if (invalid >= 0) {
|
|
1907
|
+
return { ok: false, message: `tasks[] step ${invalid + 1} must include non-blank \`agent\` and \`task\`.` };
|
|
1908
|
+
}
|
|
1909
|
+
return { ok: true, steps };
|
|
1910
|
+
}
|
|
1911
|
+
|
|
1912
|
+
async function launchUtilityFamily(input: {
|
|
1913
|
+
pi: ExtensionAPI;
|
|
1914
|
+
ctx: ExtensionContext;
|
|
1915
|
+
params: VexillatioParams;
|
|
1916
|
+
runtime: SessionRuntime;
|
|
1917
|
+
signal?: AbortSignal;
|
|
1918
|
+
supervisor: LegionSupervisor;
|
|
1919
|
+
uiProxy: UIProxy;
|
|
1920
|
+
onBudgetRefused: NonNullable<ConstructorParameters<typeof Orchestrator>[0]["onBudgetRefused"]>;
|
|
1921
|
+
cwd: string;
|
|
1922
|
+
sessionCwd: string;
|
|
1923
|
+
isChild: boolean;
|
|
1924
|
+
legion?: LegionRuntimeIdentity;
|
|
1925
|
+
eventBus: LegionEventBus;
|
|
1926
|
+
onLegion: (identity: LegionRuntimeIdentity) => void;
|
|
1927
|
+
onUtilityChild: (retire: () => Promise<void>) => void;
|
|
1928
|
+
}): Promise<AgentToolResult<unknown>> {
|
|
1929
|
+
const plan = buildUtilityPlan(input.params);
|
|
1930
|
+
if (!plan.ok) return fail(plan.message, ErrorCodes.INVALID_PARAMS);
|
|
1931
|
+
|
|
1932
|
+
const catalog = loadCatalog(input.sessionCwd, input.cwd, undefined);
|
|
1933
|
+
const prepared: Array<{
|
|
1934
|
+
step: UtilityTaskItem;
|
|
1935
|
+
agent: AgentConfig;
|
|
1936
|
+
resolved: ReturnType<typeof resolveStepModel>["resolved"];
|
|
1937
|
+
modelOrigin: ModelOrigin;
|
|
1938
|
+
}> = [];
|
|
1939
|
+
for (const step of plan.steps) {
|
|
1940
|
+
const selectedAgent = findAgent(catalog.agents, step.agent);
|
|
1941
|
+
const fallbackAgent = findAgent(catalog.allAgents, step.agent);
|
|
1942
|
+
const teamMembers = catalog.settings.teams?.[catalog.team.name]?.members ?? [];
|
|
1943
|
+
const explicitlyMentionedByTeam = Boolean(
|
|
1944
|
+
!selectedAgent &&
|
|
1945
|
+
fallbackAgent &&
|
|
1946
|
+
catalog.team.name !== DEFAULT_TEAM &&
|
|
1947
|
+
teamMembers.some((member) => {
|
|
1948
|
+
const reference = typeof member === "string" ? member.trim() : member.agent.trim();
|
|
1949
|
+
if (reference === "*") return false;
|
|
1950
|
+
return findAgent(catalog.allAgents, reference)?.name === fallbackAgent.name;
|
|
1951
|
+
}),
|
|
1952
|
+
);
|
|
1953
|
+
if (explicitlyMentionedByTeam) {
|
|
1954
|
+
return fail(
|
|
1955
|
+
unknownAgentLineForCatalog(
|
|
1956
|
+
catalog.agents,
|
|
1957
|
+
catalog.allAgents,
|
|
1958
|
+
step.agent,
|
|
1959
|
+
catalog.team,
|
|
1960
|
+
catalog.settings,
|
|
1961
|
+
),
|
|
1962
|
+
ErrorCodes.INVALID_PARAMS,
|
|
1963
|
+
);
|
|
1964
|
+
}
|
|
1965
|
+
const agent = selectedAgent ?? fallbackAgent;
|
|
1966
|
+
if (!agent?.utility) {
|
|
1967
|
+
return fail(
|
|
1968
|
+
`not a utility agent: ${step.agent} (mark it with 'utility: true')`,
|
|
1969
|
+
ErrorCodes.INVALID_PARAMS,
|
|
1970
|
+
);
|
|
1971
|
+
}
|
|
1972
|
+
if (agent.disabled) {
|
|
1973
|
+
return fail(`utility agent is disabled: ${agent.name}`, ErrorCodes.DISABLED);
|
|
1974
|
+
}
|
|
1975
|
+
try {
|
|
1976
|
+
const resolvedStep = resolveStepModel({
|
|
1977
|
+
agent,
|
|
1978
|
+
step: {
|
|
1979
|
+
...(step.model !== undefined ? { model: step.model } : {}),
|
|
1980
|
+
...(step.preset !== undefined ? { preset: step.preset } : {}),
|
|
1981
|
+
},
|
|
1982
|
+
params: { ...(input.params.model !== undefined ? { model: input.params.model } : {}) },
|
|
1983
|
+
...(input.ctx.model ? { dispatchModel: `${input.ctx.model.provider}/${input.ctx.model.id}` } : {}),
|
|
1984
|
+
settings: catalog.settings,
|
|
1985
|
+
});
|
|
1986
|
+
const violation = checkModelScope(
|
|
1987
|
+
resolvedStep.resolved.model,
|
|
1988
|
+
catalog.settings.modelScope,
|
|
1989
|
+
resolvedStep.modelOrigin,
|
|
1990
|
+
);
|
|
1991
|
+
if (violation?.severity === "error") {
|
|
1992
|
+
return fail(violation.message, ErrorCodes.INVALID_PARAMS);
|
|
1993
|
+
}
|
|
1994
|
+
prepared.push({
|
|
1995
|
+
step,
|
|
1996
|
+
agent: resolvedStep.agent,
|
|
1997
|
+
resolved: resolvedStep.resolved,
|
|
1998
|
+
modelOrigin: resolvedStep.modelOrigin,
|
|
1999
|
+
});
|
|
2000
|
+
} catch (error) {
|
|
2001
|
+
return fail(`✗ ${step.agent}: ${error instanceof Error ? error.message : String(error)}`, ErrorCodes.INVALID_PARAMS);
|
|
2002
|
+
}
|
|
2003
|
+
}
|
|
2004
|
+
|
|
2005
|
+
const firstPrepared = prepared[0];
|
|
2006
|
+
const runTask = prepared.length === 1 && firstPrepared
|
|
2007
|
+
? firstPrepared.step.task
|
|
2008
|
+
: `${prepared.length} parallel utility tasks`;
|
|
2009
|
+
const rootStore = runStoreFor(input.cwd);
|
|
2010
|
+
let legion = resolveLegionIdentity({
|
|
2011
|
+
pi: input.pi,
|
|
2012
|
+
supervisor: input.supervisor,
|
|
2013
|
+
store: rootStore,
|
|
2014
|
+
cwd: input.cwd,
|
|
2015
|
+
settings: catalog.settings,
|
|
2016
|
+
eventBus: input.eventBus,
|
|
2017
|
+
cached: input.legion,
|
|
2018
|
+
sessionFile: input.ctx.sessionManager?.getSessionFile(),
|
|
2019
|
+
});
|
|
2020
|
+
if (!legion && !input.isChild) {
|
|
2021
|
+
legion = createRootLegion({
|
|
2022
|
+
pi: input.pi,
|
|
2023
|
+
supervisor: input.supervisor,
|
|
2024
|
+
store: rootStore,
|
|
2025
|
+
cwd: input.cwd,
|
|
2026
|
+
task: `vexillatio: ${runTask}`,
|
|
2027
|
+
settings: catalog.settings,
|
|
2028
|
+
maxDepth: effectiveMaxDepth(catalog.settings.legion?.maxDepth),
|
|
2029
|
+
eventBus: input.eventBus,
|
|
2030
|
+
});
|
|
2031
|
+
}
|
|
2032
|
+
if (legion) input.onLegion(legion);
|
|
2033
|
+
|
|
2034
|
+
const store = legion?.runStore ?? rootStore;
|
|
2035
|
+
const run = legion
|
|
2036
|
+
? store.readRun(legion.runId)
|
|
2037
|
+
: store.createRun({
|
|
2038
|
+
task: runTask,
|
|
2039
|
+
cwd: input.cwd,
|
|
2040
|
+
maxDepth: effectiveMaxDepth(catalog.settings.legion?.maxDepth),
|
|
2041
|
+
path: parseNestedPathEnv(process.env.PI_SUBAGENT_PARENT_PATH),
|
|
2042
|
+
herdr: { supervisor: "rpc" },
|
|
2043
|
+
});
|
|
2044
|
+
if (!run) return fail("vexillatio parent run is not available", ErrorCodes.NOT_FOUND);
|
|
2045
|
+
const runId = run.runId;
|
|
2046
|
+
const timeoutMs = input.params.timeoutMs ?? 600_000;
|
|
2047
|
+
// §2.6: continue numbering from this parent's existing children only —
|
|
2048
|
+
// do NOT scan store.listRuns() (that made names climb across sessions, #42).
|
|
2049
|
+
const existingNames: string[] = legion
|
|
2050
|
+
? getChildren(legion.db, legion.nodeId).map((child) => child.name)
|
|
2051
|
+
: run.children.map((child) => child.name);
|
|
2052
|
+
const allocated = allocateSiblingNames(
|
|
2053
|
+
prepared.map((item) => item.agent.name),
|
|
2054
|
+
existingNames,
|
|
2055
|
+
(name) => input.supervisor.isAlive(name),
|
|
2056
|
+
);
|
|
2057
|
+
const names = new Map<UtilityTaskItem, string>();
|
|
2058
|
+
for (let i = 0; i < prepared.length; i++) names.set(prepared[i]!.step, allocated[i]!);
|
|
2059
|
+
|
|
2060
|
+
const results: string[] = [];
|
|
2061
|
+
const stillRunning = new Set<string>();
|
|
2062
|
+
const launches = await Promise.all(prepared.map(async (item) => {
|
|
2063
|
+
const cwd = item.step.cwd ? path.resolve(input.cwd, item.step.cwd) : input.cwd;
|
|
2064
|
+
const orchestrator = new Orchestrator({
|
|
2065
|
+
supervisor: input.supervisor,
|
|
2066
|
+
uiProxy: input.uiProxy,
|
|
2067
|
+
runDir: legion?.runDir ?? store.runDir(runId),
|
|
2068
|
+
cwd,
|
|
2069
|
+
maxDepth: catalog.settings.legion?.maxDepth,
|
|
2070
|
+
maxSpawns: catalog.settings.maxSubagentSpawnsPerSession ?? null,
|
|
2071
|
+
childContext: catalog.settings.childContext,
|
|
2072
|
+
team: catalog.team,
|
|
2073
|
+
onBudgetRefused: input.onBudgetRefused,
|
|
2074
|
+
...(legion ? {
|
|
2075
|
+
legion: {
|
|
2076
|
+
db: legion.db,
|
|
2077
|
+
dbPath: legion.dbPath,
|
|
2078
|
+
nodeId: legion.nodeId,
|
|
2079
|
+
runId,
|
|
2080
|
+
parentNodeId: legion.parentNodeId,
|
|
2081
|
+
parentRunId: legion.parentRunId ?? undefined,
|
|
2082
|
+
runStore: store,
|
|
2083
|
+
settings: legion.settings,
|
|
2084
|
+
eventBus: legion.eventBus,
|
|
2085
|
+
},
|
|
2086
|
+
} : {}),
|
|
2087
|
+
});
|
|
2088
|
+
orchestrator.restore(run);
|
|
2089
|
+
try {
|
|
2090
|
+
const handle = await orchestrator.launch({
|
|
2091
|
+
agent: item.agent,
|
|
2092
|
+
task: item.step.task,
|
|
2093
|
+
name: names.get(item.step),
|
|
2094
|
+
...(item.resolved.model ? { model: item.resolved.model } : {}),
|
|
2095
|
+
modelOrigin: item.modelOrigin,
|
|
2096
|
+
...(item.step.worktree !== undefined ? { worktree: item.step.worktree } : {}),
|
|
2097
|
+
utilityDispatch: true,
|
|
2098
|
+
stripLegionIdentity: true,
|
|
2099
|
+
});
|
|
2100
|
+
await persistChild(store, runId, orchestrator, handle.name);
|
|
2101
|
+
let retirement: Promise<void> | undefined;
|
|
2102
|
+
const retire = (): Promise<void> => {
|
|
2103
|
+
if (!retirement) {
|
|
2104
|
+
const current = (async () => {
|
|
2105
|
+
await orchestrator.retire(handle.name);
|
|
2106
|
+
await persistChild(store, runId, orchestrator, handle.name);
|
|
2107
|
+
})();
|
|
2108
|
+
retirement = current;
|
|
2109
|
+
void current.catch(() => {
|
|
2110
|
+
if (retirement === current) retirement = undefined;
|
|
2111
|
+
});
|
|
2112
|
+
}
|
|
2113
|
+
return retirement;
|
|
2114
|
+
};
|
|
2115
|
+
input.onUtilityChild(async () => {
|
|
2116
|
+
try {
|
|
2117
|
+
await retire();
|
|
2118
|
+
} catch {
|
|
2119
|
+
// Shutdown cleanup is best-effort; the supervisor owns final process state.
|
|
2120
|
+
}
|
|
2121
|
+
});
|
|
2122
|
+
return {
|
|
2123
|
+
item,
|
|
2124
|
+
value: {
|
|
2125
|
+
name: handle.name,
|
|
2126
|
+
agent: item.agent.name,
|
|
2127
|
+
sessionFile: handle.sessionFile,
|
|
2128
|
+
orchestrator,
|
|
2129
|
+
retire,
|
|
2130
|
+
},
|
|
2131
|
+
};
|
|
2132
|
+
} catch (error) {
|
|
2133
|
+
return {
|
|
2134
|
+
item,
|
|
2135
|
+
error: error instanceof Error ? error.message : String(error),
|
|
2136
|
+
};
|
|
2137
|
+
}
|
|
2138
|
+
}));
|
|
2139
|
+
const handles: Array<{
|
|
2140
|
+
name: string;
|
|
2141
|
+
agent: string;
|
|
2142
|
+
sessionFile: string;
|
|
2143
|
+
orchestrator: Orchestrator;
|
|
2144
|
+
retire: () => Promise<void>;
|
|
2145
|
+
}> = [];
|
|
2146
|
+
for (const launch of launches) {
|
|
2147
|
+
if ("error" in launch) results.push(`✗ ${launch.item.agent.name}: ${launch.error}`);
|
|
2148
|
+
else handles.push(launch.value);
|
|
2149
|
+
}
|
|
2150
|
+
|
|
2151
|
+
const followUtility = (handle: (typeof handles)[number], watch: boolean): void => {
|
|
2152
|
+
followJob(input.runtime, {
|
|
2153
|
+
name: handle.name,
|
|
2154
|
+
runId,
|
|
2155
|
+
agent: handle.agent,
|
|
2156
|
+
sessionFile: handle.sessionFile,
|
|
2157
|
+
timeoutMs,
|
|
2158
|
+
orchestrator: handle.orchestrator,
|
|
2159
|
+
store,
|
|
2160
|
+
watch,
|
|
2161
|
+
retire: handle.retire,
|
|
2162
|
+
});
|
|
2163
|
+
};
|
|
2164
|
+
for (const handle of handles) followUtility(handle, false);
|
|
2165
|
+
|
|
2166
|
+
const collected = await Promise.all(handles.map(async (handle) => {
|
|
2167
|
+
try {
|
|
2168
|
+
const result = await handle.orchestrator.collect(handle.name, {
|
|
2169
|
+
timeoutMs,
|
|
2170
|
+
...(input.signal ? { signal: input.signal } : {}),
|
|
2171
|
+
});
|
|
2172
|
+
await persistChild(store, runId, handle.orchestrator, handle.name);
|
|
2173
|
+
if (result.blocked || !shouldRecycleAfterCollect(result.execution.status)) {
|
|
2174
|
+
followUtility(handle, true);
|
|
2175
|
+
stillRunning.add(handle.name);
|
|
2176
|
+
} else {
|
|
2177
|
+
await handle.retire();
|
|
2178
|
+
input.runtime.release(handle.name);
|
|
2179
|
+
}
|
|
2180
|
+
const lines = [
|
|
2181
|
+
`── ${handle.name} ──`,
|
|
2182
|
+
`execution: ${result.execution.status}${result.execution.reason ? ` (${result.execution.reason})` : ""}`,
|
|
2183
|
+
result.output || "(no output)",
|
|
2184
|
+
];
|
|
2185
|
+
if (result.outputFile) lines.push(`outputFile: ${result.outputFile}`);
|
|
2186
|
+
lines.push(`sessionFile: ${handle.sessionFile}`);
|
|
2187
|
+
return lines.join("\n");
|
|
2188
|
+
} catch (error) {
|
|
2189
|
+
if (input.supervisor.isAlive(handle.name)) {
|
|
2190
|
+
followUtility(handle, true);
|
|
2191
|
+
stillRunning.add(handle.name);
|
|
2192
|
+
} else {
|
|
2193
|
+
await handle.retire().catch(() => {});
|
|
2194
|
+
input.runtime.release(handle.name);
|
|
2195
|
+
}
|
|
2196
|
+
return `✗ ${handle.name}: collect failed: ${error instanceof Error ? error.message : String(error)}\nsessionFile: ${handle.sessionFile}`;
|
|
2197
|
+
}
|
|
2198
|
+
}));
|
|
2199
|
+
if (input.signal?.aborted) return ok("interrupted (parent turn aborted)");
|
|
2200
|
+
results.push(...collected);
|
|
2201
|
+
return {
|
|
2202
|
+
content: [{ type: "text", text: results.join("\n\n") || "No utility agents were launched." }],
|
|
2203
|
+
details: {
|
|
2204
|
+
runId,
|
|
2205
|
+
handles: handles.map(({ name, agent, sessionFile }) => ({ name, agent, sessionFile })),
|
|
2206
|
+
async: false,
|
|
2207
|
+
...(stillRunning.size > 0
|
|
2208
|
+
? { stillRunning: handles.filter(({ name }) => stillRunning.has(name)).map(({ name }) => name) }
|
|
2209
|
+
: {}),
|
|
2210
|
+
},
|
|
2211
|
+
};
|
|
2212
|
+
}
|
|
2213
|
+
|
|
1162
2214
|
export function buildPlan(params: {
|
|
1163
2215
|
agent?: string;
|
|
1164
2216
|
task?: string;
|
|
@@ -1169,8 +2221,10 @@ export function buildPlan(params: {
|
|
|
1169
2221
|
model?: string;
|
|
1170
2222
|
preset?: string;
|
|
1171
2223
|
worktree?: boolean;
|
|
2224
|
+
verify?: string[];
|
|
1172
2225
|
}>;
|
|
1173
2226
|
worktree?: boolean;
|
|
2227
|
+
verify?: string[];
|
|
1174
2228
|
}): Plan {
|
|
1175
2229
|
const hasSingle = isPresent(params.agent) && isPresent(params.task);
|
|
1176
2230
|
const hasTasks = Boolean(params.tasks?.length);
|
|
@@ -1219,6 +2273,7 @@ interface PlanStep {
|
|
|
1219
2273
|
model?: string;
|
|
1220
2274
|
preset?: string;
|
|
1221
2275
|
worktree?: boolean;
|
|
2276
|
+
verify?: string[];
|
|
1222
2277
|
}
|
|
1223
2278
|
|
|
1224
2279
|
/**
|
|
@@ -1264,13 +2319,14 @@ function followJob(
|
|
|
1264
2319
|
orchestrator: Orchestrator;
|
|
1265
2320
|
store: RunStore;
|
|
1266
2321
|
watch: boolean;
|
|
2322
|
+
retire?: () => Promise<void>;
|
|
2323
|
+
treeIdentity?: { nodeId: string; parentId: string | null; depth: number };
|
|
1267
2324
|
},
|
|
1268
2325
|
): void {
|
|
1269
2326
|
const child = input.orchestrator
|
|
1270
2327
|
.childrenSnapshot()
|
|
1271
2328
|
.find((entry) => entry.name === input.name);
|
|
1272
2329
|
const existing = runtime.get(input.name);
|
|
1273
|
-
const nonPi = child?.kind && child.kind !== "pi";
|
|
1274
2330
|
runtime.track({
|
|
1275
2331
|
name: input.name,
|
|
1276
2332
|
runId: input.runId,
|
|
@@ -1281,14 +2337,24 @@ function followJob(
|
|
|
1281
2337
|
...(child?.model ? { model: child.model } : {}),
|
|
1282
2338
|
...(child?.thinking !== undefined ? { thinking: child.thinking } : {}),
|
|
1283
2339
|
...(child?.worktreeBranch ? { worktreeBranch: child.worktreeBranch } : {}),
|
|
1284
|
-
...(
|
|
1285
|
-
|
|
1286
|
-
:
|
|
1287
|
-
|
|
1288
|
-
|
|
2340
|
+
...(input.treeIdentity ? input.treeIdentity : child?.nodeId ? {
|
|
2341
|
+
nodeId: child.nodeId,
|
|
2342
|
+
parentId: child.nodeId.includes(".") ? child.nodeId.slice(0, child.nodeId.lastIndexOf(".")) : null,
|
|
2343
|
+
depth: child.nodeId.split(".").length - 1,
|
|
2344
|
+
} : {}),
|
|
2345
|
+
collect: (opts) =>
|
|
2346
|
+
input.orchestrator.collect(input.name, {
|
|
2347
|
+
timeoutMs: input.timeoutMs,
|
|
2348
|
+
...(opts?.signal ? { signal: opts.signal } : {}),
|
|
2349
|
+
}),
|
|
2350
|
+
isAlive: () => input.orchestrator.isChildAlive(input.name),
|
|
1289
2351
|
persist: async () =>
|
|
1290
2352
|
persistChild(input.store, input.runId, input.orchestrator, input.name),
|
|
1291
2353
|
retire: async () => {
|
|
2354
|
+
if (input.retire) {
|
|
2355
|
+
await input.retire();
|
|
2356
|
+
return;
|
|
2357
|
+
}
|
|
1292
2358
|
await input.orchestrator.retire(input.name);
|
|
1293
2359
|
await persistChild(
|
|
1294
2360
|
input.store,
|
|
@@ -1337,6 +2403,13 @@ function followChild(ctx: ChildContext, opts: { watch: boolean }): void {
|
|
|
1337
2403
|
orchestrator: ctx.orchestrator,
|
|
1338
2404
|
store: ctx.store,
|
|
1339
2405
|
watch: opts.watch,
|
|
2406
|
+
...(ctx.found.child.nodeId ? {
|
|
2407
|
+
treeIdentity: {
|
|
2408
|
+
nodeId: ctx.found.child.nodeId,
|
|
2409
|
+
parentId: ctx.found.child.nodeId.includes(".") ? ctx.found.child.nodeId.slice(0, ctx.found.child.nodeId.lastIndexOf(".")) : null,
|
|
2410
|
+
depth: ctx.found.child.nodeId.split(".").length - 1,
|
|
2411
|
+
},
|
|
2412
|
+
} : {}),
|
|
1340
2413
|
});
|
|
1341
2414
|
}
|
|
1342
2415
|
|
|
@@ -1357,6 +2430,13 @@ function followLaunched(
|
|
|
1357
2430
|
orchestrator: session.orchestrator,
|
|
1358
2431
|
store: session.store,
|
|
1359
2432
|
watch: opts.watch,
|
|
2433
|
+
...(child?.nodeId ? {
|
|
2434
|
+
treeIdentity: {
|
|
2435
|
+
nodeId: child.nodeId,
|
|
2436
|
+
parentId: child.nodeId.includes(".") ? child.nodeId.slice(0, child.nodeId.lastIndexOf(".")) : null,
|
|
2437
|
+
depth: child.nodeId.split(".").length - 1,
|
|
2438
|
+
},
|
|
2439
|
+
} : {}),
|
|
1360
2440
|
});
|
|
1361
2441
|
}
|
|
1362
2442
|
|
|
@@ -1372,13 +2452,44 @@ async function persistChild(
|
|
|
1372
2452
|
runId: string,
|
|
1373
2453
|
orchestrator: Orchestrator,
|
|
1374
2454
|
name: string,
|
|
2455
|
+
opts: { preserveRetired?: boolean } = {},
|
|
1375
2456
|
): Promise<void> {
|
|
1376
2457
|
const child = orchestrator.childrenSnapshot().find((c) => c.name === name);
|
|
1377
2458
|
if (!child) return;
|
|
1378
2459
|
try {
|
|
1379
|
-
|
|
2460
|
+
const recorded = store.findChild(runId, name);
|
|
2461
|
+
if (!recorded) {
|
|
2462
|
+
await store.addChild(runId, child);
|
|
2463
|
+
return;
|
|
2464
|
+
}
|
|
2465
|
+
if (recorded.nodeId !== child.nodeId) return;
|
|
2466
|
+
await store.updateChild(runId, name, (current) => {
|
|
2467
|
+
if (opts.preserveRetired && current.state === "retired" && child.state !== "retired") return;
|
|
2468
|
+
Object.assign(current, child);
|
|
2469
|
+
});
|
|
1380
2470
|
} catch {
|
|
1381
|
-
//
|
|
2471
|
+
// Persistence is best-effort when a competing lifecycle callback wins the write.
|
|
2472
|
+
}
|
|
2473
|
+
}
|
|
2474
|
+
|
|
2475
|
+
async function abandonLaunchedChild(session: LaunchSession, name: string): Promise<void> {
|
|
2476
|
+
const child = session.orchestrator.childrenSnapshot().find((entry) => entry.name === name);
|
|
2477
|
+
if (!child) return;
|
|
2478
|
+
if (child.state === "retired") {
|
|
2479
|
+
session.runtime.release(name);
|
|
2480
|
+
return;
|
|
2481
|
+
}
|
|
2482
|
+
child.execution = { status: "failed", reason: "launch abandoned because parent turn aborted" };
|
|
2483
|
+
child.acceptance = { status: "rejected", level: "none", reason: "aborted by parent" };
|
|
2484
|
+
try {
|
|
2485
|
+
await session.orchestrator.retire(name);
|
|
2486
|
+
await persistChild(session.store, session.runId, session.orchestrator, name);
|
|
2487
|
+
session.runtime.release(name);
|
|
2488
|
+
session.results.push(`✗ ${name}: abandoned because parent turn aborted (child retired)`);
|
|
2489
|
+
} catch (error) {
|
|
2490
|
+
await persistChild(session.store, session.runId, session.orchestrator, name);
|
|
2491
|
+
followLaunched(session, name, { watch: true });
|
|
2492
|
+
session.results.push(`✗ ${name}: abandon retire failed: ${String(error)}; completion remains watched`);
|
|
1382
2493
|
}
|
|
1383
2494
|
}
|
|
1384
2495
|
|
|
@@ -1421,6 +2532,7 @@ export function resolveWaitTargets(
|
|
|
1421
2532
|
export function renderWait(results: WaitResult[]): string {
|
|
1422
2533
|
let done = 0;
|
|
1423
2534
|
let running = 0;
|
|
2535
|
+
let interrupted = 0;
|
|
1424
2536
|
const parts: string[] = [];
|
|
1425
2537
|
for (const result of results) {
|
|
1426
2538
|
if (result.snapshot) {
|
|
@@ -1429,23 +2541,83 @@ export function renderWait(results: WaitResult[]): string {
|
|
|
1429
2541
|
} else if (result.stillRunning) {
|
|
1430
2542
|
running += 1;
|
|
1431
2543
|
parts.push(`── ${result.name} ──\nstill running`);
|
|
2544
|
+
} else if (result.interrupted) {
|
|
2545
|
+
interrupted += 1;
|
|
2546
|
+
parts.push(`── ${result.name} ──\ninterrupted (parent turn aborted)`);
|
|
1432
2547
|
} else {
|
|
1433
2548
|
parts.push(`── ${result.name} ──\nnot tracked (no live job, no finished cache)`);
|
|
1434
2549
|
}
|
|
1435
2550
|
}
|
|
1436
2551
|
parts.push(`${done} done, ${running} still running`);
|
|
2552
|
+
if (interrupted > 0) parts.push(`${interrupted} interrupted`);
|
|
1437
2553
|
return parts.join("\n");
|
|
1438
2554
|
}
|
|
1439
2555
|
|
|
2556
|
+
/** Gracefully retire a named child or every child tracked by this session. */
|
|
2557
|
+
export async function stopAction(input: {
|
|
2558
|
+
params: { name?: string; all?: boolean };
|
|
2559
|
+
runtime: SessionRuntime;
|
|
2560
|
+
retire: (name: string) => Promise<void>;
|
|
2561
|
+
}): Promise<AgentToolResult<unknown>> {
|
|
2562
|
+
const { params, runtime, retire } = input;
|
|
2563
|
+
if (params.name && params.all) {
|
|
2564
|
+
return fail("`name` and `all` are mutually exclusive for stop.", ErrorCodes.INVALID_PARAMS);
|
|
2565
|
+
}
|
|
2566
|
+
if (!params.name && !params.all) {
|
|
2567
|
+
return fail("`name` or `all` is required for stop", ErrorCodes.INVALID_PARAMS);
|
|
2568
|
+
}
|
|
2569
|
+
const jobs = runtime.activeJobs();
|
|
2570
|
+
const active = jobs.filter((job) => job.state !== "retired");
|
|
2571
|
+
let targets: string[];
|
|
2572
|
+
if (params.all) {
|
|
2573
|
+
targets = active.map((job) => job.name);
|
|
2574
|
+
} else {
|
|
2575
|
+
const target = jobs.find((job) => job.name === params.name);
|
|
2576
|
+
if (!target) {
|
|
2577
|
+
return fail(`unknown child: ${params.name} (not tracked by this session).`, ErrorCodes.NOT_FOUND);
|
|
2578
|
+
}
|
|
2579
|
+
targets = target.state === "retired" ? [] : [target.name];
|
|
2580
|
+
}
|
|
2581
|
+
if (targets.length === 0) return ok("No non-retired children to stop.");
|
|
2582
|
+
|
|
2583
|
+
// Stop every watcher before any retire starts: a later target must not finish
|
|
2584
|
+
// its own watch (and notify/release) while an earlier target is being retired.
|
|
2585
|
+
for (const name of targets) runtime.cancelWatch(name);
|
|
2586
|
+
|
|
2587
|
+
const stopped: string[] = [];
|
|
2588
|
+
const failures: string[] = [];
|
|
2589
|
+
for (const name of targets) {
|
|
2590
|
+
try {
|
|
2591
|
+
// The runtime job may have been released after the target snapshot was
|
|
2592
|
+
// taken. The retire adapter can then resolve its persisted run record.
|
|
2593
|
+
await retire(name);
|
|
2594
|
+
runtime.release(name);
|
|
2595
|
+
stopped.push(name);
|
|
2596
|
+
} catch (error) {
|
|
2597
|
+
// If the graceful-retire chain fails, restore the completion watcher so a
|
|
2598
|
+
// still-live child is not orphaned by the explicit stop attempt.
|
|
2599
|
+
if (runtime.get(name)) runtime.rewatch(name);
|
|
2600
|
+
failures.push(`${name}: ${String(error)}`);
|
|
2601
|
+
}
|
|
2602
|
+
}
|
|
2603
|
+
const summary = stopped.length
|
|
2604
|
+
? `Stopped ${stopped.length} child${stopped.length === 1 ? "" : "ren"}: ${stopped.join(", ")}.`
|
|
2605
|
+
: "No children were stopped.";
|
|
2606
|
+
return failures.length
|
|
2607
|
+
? fail(`${summary}\nFailed to stop: ${failures.join("; ")}`, ErrorCodes.RETIRE_FAILED)
|
|
2608
|
+
: ok(summary);
|
|
2609
|
+
}
|
|
2610
|
+
|
|
1440
2611
|
export async function waitAction(input: {
|
|
1441
2612
|
params: SubagentParams;
|
|
2613
|
+
signal?: AbortSignal;
|
|
1442
2614
|
runtime: SessionRuntime;
|
|
1443
2615
|
agents: AgentConfig[];
|
|
1444
2616
|
allAgents?: AgentConfig[];
|
|
1445
2617
|
store: RunStore;
|
|
1446
2618
|
cwd: string;
|
|
1447
2619
|
}): Promise<AgentToolResult<unknown>> {
|
|
1448
|
-
const { params, runtime, agents, allAgents = agents, store, cwd } = input;
|
|
2620
|
+
const { params, signal, runtime, agents, allAgents = agents, store, cwd } = input;
|
|
1449
2621
|
const jobs = runtime.activeJobs();
|
|
1450
2622
|
let targets = resolveWaitTargets(
|
|
1451
2623
|
{ name: params.name, all: params.all },
|
|
@@ -1480,7 +2652,7 @@ export async function waitAction(input: {
|
|
|
1480
2652
|
);
|
|
1481
2653
|
}),
|
|
1482
2654
|
);
|
|
1483
|
-
const results = await runtime.wait(targets.names, { timeoutMs });
|
|
2655
|
+
const results = await runtime.wait(targets.names, { timeoutMs, ...(signal ? { signal } : {}) });
|
|
1484
2656
|
return ok(renderWait(results));
|
|
1485
2657
|
}
|
|
1486
2658
|
|
|
@@ -1559,9 +2731,7 @@ function findChild(
|
|
|
1559
2731
|
run: ReturnType<RunStore["listRuns"]>[number];
|
|
1560
2732
|
child: NonNullable<ReturnType<RunStore["findChild"]>>;
|
|
1561
2733
|
} | null {
|
|
1562
|
-
const picked = pickChildByName(runs, name
|
|
1563
|
-
parentPaneId: process.env.HERDR_PANE_ID,
|
|
1564
|
-
});
|
|
2734
|
+
const picked = pickChildByName(runs, name);
|
|
1565
2735
|
if (!picked) return null;
|
|
1566
2736
|
return { runId: picked.run.runId, run: picked.run, child: picked.child };
|
|
1567
2737
|
}
|
|
@@ -1572,7 +2742,7 @@ function renderChild(
|
|
|
1572
2742
|
): AgentToolResult<unknown> {
|
|
1573
2743
|
const lines = [
|
|
1574
2744
|
`${child.name} — state=${child.state} agent=${child.agent ?? "?"} kind=${child.kind ?? "?"}`,
|
|
1575
|
-
|
|
2745
|
+
"supervisor=rpc",
|
|
1576
2746
|
`session=${child.sessionFile}`,
|
|
1577
2747
|
];
|
|
1578
2748
|
if (child.execution) {
|
|
@@ -1603,6 +2773,7 @@ function renderCollect(
|
|
|
1603
2773
|
acceptance: {
|
|
1604
2774
|
status: string;
|
|
1605
2775
|
level?: string;
|
|
2776
|
+
reason?: string;
|
|
1606
2777
|
pendingCriteria?: Array<{
|
|
1607
2778
|
id: string;
|
|
1608
2779
|
must: string;
|
|
@@ -1617,9 +2788,14 @@ function renderCollect(
|
|
|
1617
2788
|
`execution: ${collected.execution.status}${collected.execution.reason ? ` (${collected.execution.reason})` : ""}`,
|
|
1618
2789
|
`acceptance: ${collected.acceptance.status}${collected.acceptance.level ? ` (${collected.acceptance.level})` : ""}`,
|
|
1619
2790
|
];
|
|
2791
|
+
// The mechanical verify outcome (which command failed / which commands had
|
|
2792
|
+
// to pass) is the parent's primary trust anchor — surface it inline.
|
|
2793
|
+
if (collected.acceptance.reason && /^(verified by|verification failed)/.test(collected.acceptance.reason)) {
|
|
2794
|
+
lines.push(collected.acceptance.reason);
|
|
2795
|
+
}
|
|
1620
2796
|
if (collected.blocked) {
|
|
1621
2797
|
lines.push(
|
|
1622
|
-
"blocked: waiting for
|
|
2798
|
+
"blocked: waiting for child UI approval. Answer the pending request in the parent UI, or steer the child.",
|
|
1623
2799
|
);
|
|
1624
2800
|
}
|
|
1625
2801
|
|