pi-legion 2.0.0 → 2.0.2

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.
@@ -0,0 +1,172 @@
1
+ /**
2
+ * `/legion status [name]` — chat text for humans.
3
+ *
4
+ * No name: the whole tree, same lines as `formatTree` (the legion_tree summary).
5
+ * A name: `renderLiveChildStatus`, the same text as `subagent({ action: "status" })`
6
+ * when a run-store child record is available.
7
+ */
8
+
9
+ import { NODE_STATUSES, type LegionNode } from "../legion/nodes.ts";
10
+ import { formatTree } from "./legion-tools.ts";
11
+ import {
12
+ renderLiveChildStatus,
13
+ type LiveChildStatusInput,
14
+ } from "./runtime.ts";
15
+
16
+ /** Child snapshot the status tool already prints. `now` is supplied by the caller. */
17
+ export type LegionChildStatusFacts = Omit<LiveChildStatusInput, "now">;
18
+
19
+ export interface LegionStatusJob {
20
+ name: string;
21
+ nodeId?: string;
22
+ state: string;
23
+ }
24
+
25
+ export interface LegionStatusInput {
26
+ /** Empty shows the tree. Otherwise a node id, short id, or child name. */
27
+ query?: string;
28
+ nodes: readonly LegionNode[];
29
+ jobs?: readonly LegionStatusJob[];
30
+ /**
31
+ * Persisted child behind the subagent status action.
32
+ * Undefined when this process has no run-store record for the name.
33
+ */
34
+ lookupChild?: (name: string) => LegionChildStatusFacts | undefined;
35
+ now?: number;
36
+ }
37
+
38
+ export interface LegionStatusReport {
39
+ ok: boolean;
40
+ text: string;
41
+ }
42
+
43
+ const LIVE_NODE_STATUSES = new Set(["starting", "running", "blocked"]);
44
+
45
+ function nodesMatching(nodes: readonly LegionNode[], query: string): LegionNode[] {
46
+ const byId = nodes.filter((node) => node.id === query);
47
+ if (byId.length > 0) return byId;
48
+ return nodes.filter((node) =>
49
+ node.name === query || node.id.slice(node.id.lastIndexOf(".") + 1) === query,
50
+ );
51
+ }
52
+
53
+ function statusCounts(nodes: readonly LegionNode[]): string {
54
+ const counts = new Map<string, number>();
55
+ for (const node of nodes) counts.set(node.status, (counts.get(node.status) ?? 0) + 1);
56
+ const known = new Set<string>(NODE_STATUSES);
57
+ const ordered = [
58
+ ...NODE_STATUSES.filter((status) => counts.has(status)),
59
+ ...[...counts.keys()].filter((status) => !known.has(status)),
60
+ ];
61
+ return ordered.map((status) => `${status}=${counts.get(status)}`).join(", ");
62
+ }
63
+
64
+ function withNow(facts: LegionChildStatusFacts, now: number | undefined): LiveChildStatusInput {
65
+ return now === undefined ? facts : { ...facts, now };
66
+ }
67
+
68
+ function liveJobForNode(node: LegionNode, jobs: readonly LegionStatusJob[]): LegionStatusJob | undefined {
69
+ return jobs.find((job) =>
70
+ job.state !== "retired" && (job.nodeId === node.id || (job.nodeId === undefined && job.name === node.name)),
71
+ );
72
+ }
73
+
74
+ function renderFacts(facts: LegionChildStatusFacts, now: number | undefined): string {
75
+ return renderLiveChildStatus(withNow(facts, now));
76
+ }
77
+
78
+ function renderNode(
79
+ node: LegionNode,
80
+ jobs: readonly LegionStatusJob[],
81
+ lookupChild: LegionStatusInput["lookupChild"],
82
+ now: number | undefined,
83
+ ): string {
84
+ const facts = lookupChild?.(node.name);
85
+ if (facts) return renderFacts(facts, now);
86
+ const job = liveJobForNode(node, jobs);
87
+ const alive = job
88
+ ? job.state === "working" || job.state === "blocked"
89
+ : LIVE_NODE_STATUSES.has(node.status);
90
+ return renderLiveChildStatus(withNow({
91
+ name: node.name,
92
+ state: node.status,
93
+ agent: node.role,
94
+ kind: node.kind,
95
+ sessionFile: node.sessionFile ?? "",
96
+ alive,
97
+ }, now));
98
+ }
99
+
100
+ function renderJob(
101
+ job: LegionStatusJob,
102
+ lookupChild: LegionStatusInput["lookupChild"],
103
+ now: number | undefined,
104
+ ): string {
105
+ const facts = lookupChild?.(job.name);
106
+ if (facts) return renderFacts(facts, now);
107
+ return renderLiveChildStatus(withNow({
108
+ name: job.name,
109
+ state: job.state,
110
+ sessionFile: "",
111
+ alive: job.state === "working" || job.state === "blocked",
112
+ }, now));
113
+ }
114
+
115
+ function jobsOutsideTree(nodes: readonly LegionNode[], jobs: readonly LegionStatusJob[]): LegionStatusJob[] {
116
+ const ids = new Set(nodes.map((node) => node.id));
117
+ const names = new Set(nodes.map((node) => node.name));
118
+ return jobs.filter((job) =>
119
+ job.state !== "retired" &&
120
+ (job.nodeId === undefined || !ids.has(job.nodeId)) &&
121
+ !names.has(job.name),
122
+ );
123
+ }
124
+
125
+ function renderTree(nodes: readonly LegionNode[], jobs: readonly LegionStatusJob[]): string {
126
+ const noun = nodes.length === 1 ? "node" : "nodes";
127
+ const counts = nodes.length > 0 ? ` · ${statusCounts(nodes)}` : "";
128
+ const lines = [`Legion status · ${nodes.length} ${noun}${counts}`];
129
+ lines.push("");
130
+ lines.push(nodes.length > 0 ? formatTree(nodes) : "No legion nodes.");
131
+ const extras = jobsOutsideTree(nodes, jobs);
132
+ if (extras.length > 0) {
133
+ lines.push("", "Live jobs not in the tree:");
134
+ for (const job of extras) lines.push(`- ${job.name} state=${job.state}`);
135
+ }
136
+ lines.push("", "One node: /legion status <name>");
137
+ return lines.join("\n");
138
+ }
139
+
140
+ /** Human-readable `/legion status` text. `ok: false` is an unknown or ambiguous name. */
141
+ export function renderLegionStatus(input: LegionStatusInput): LegionStatusReport {
142
+ const query = input.query?.trim() ?? "";
143
+ const jobs = input.jobs ?? [];
144
+ if (!query) return { ok: true, text: renderTree(input.nodes, jobs) };
145
+
146
+ const matches = nodesMatching(input.nodes, query);
147
+ if (matches.length > 1) {
148
+ return {
149
+ ok: false,
150
+ text: `"${query}" is ambiguous; use a full node id with /legion status <node-id>.`,
151
+ };
152
+ }
153
+ if (matches.length === 1) {
154
+ return { ok: true, text: renderNode(matches[0]!, jobs, input.lookupChild, input.now) };
155
+ }
156
+
157
+ const namedJobs = jobs.filter((job) => job.name === query || job.nodeId === query);
158
+ if (namedJobs.length > 1) {
159
+ return {
160
+ ok: false,
161
+ text: `"${query}" is ambiguous; use a full node id with /legion status <node-id>.`,
162
+ };
163
+ }
164
+ if (namedJobs.length === 1) {
165
+ return { ok: true, text: renderJob(namedJobs[0]!, input.lookupChild, input.now) };
166
+ }
167
+
168
+ const facts = input.lookupChild?.(query);
169
+ if (facts) return { ok: true, text: renderFacts(facts, input.now) };
170
+
171
+ return { ok: false, text: `Unknown Legion node "${query}".` };
172
+ }
@@ -3,10 +3,9 @@
3
3
  * - `/legion stop` (all) / `/legion stop <name>` — thin wrapper over the
4
4
  * subagent tool's `stop` action (graceful retire; a retired parent's own
5
5
  * session shutdown retires its subtree, so stopping a parent cascades).
6
- * - Esc while the main agent is idle, the editor is empty and children are
7
- * live → confirm → stop all.
8
6
  * - `x` on the selected agent in the /legion view → confirm → stop it.
9
- * Everything here is pure/injectable so it can be unit tested.
7
+ * Esc no longer stops children (esc-no-retire-v3); only `/legion stop` or
8
+ * process exit retires. Everything here is pure/injectable for unit tests.
10
9
  */
11
10
 
12
11
  export interface StopJob {
@@ -114,37 +113,6 @@ export async function runLegionStop(arg: string, deps: StopDeps): Promise<string
114
113
  return say(result.text, result.ok ? "info" : "error");
115
114
  }
116
115
 
117
- export interface IdleEscDeps {
118
- /** Main agent not streaming / running a turn. */
119
- isIdle(): boolean;
120
- editorEmpty(): boolean;
121
- /** An overlay, dialog or non-editor component has focus. */
122
- uiBusy(): boolean;
123
- liveCount(): number;
124
- confirm(title: string, message: string): Promise<boolean>;
125
- stopAll(): Promise<void>;
126
- }
127
-
128
- /**
129
- * Raw terminal input hook: consumes a bare Esc only when the main agent is
130
- * idle, the editor is empty, nothing else owns focus and children are live.
131
- * Every other key (and Esc in any other state) passes through to pi.
132
- */
133
- export function createIdleEscStop(deps: IdleEscDeps): (data: string) => { consume: true } | undefined {
134
- let prompting = false;
135
- return (data) => {
136
- if (data !== "\x1b" || prompting) return undefined;
137
- const count = deps.liveCount();
138
- if (count === 0 || !deps.isIdle() || !deps.editorEmpty() || deps.uiBusy()) return undefined;
139
- prompting = true;
140
- void deps.confirm("Stop subagents", `Stop ${count} running subagent${count === 1 ? "" : "s"}?`)
141
- .then((yes) => (yes ? deps.stopAll() : undefined))
142
- .catch(() => {})
143
- .finally(() => { prompting = false; });
144
- return { consume: true };
145
- };
146
- }
147
-
148
- /** Dim hint shown in the railway header while the main agent is idle and children are live. */
149
- export const IDLE_STOP_HINT = "esc stop all · /legion stop [name]";
150
- export const IDLE_STOP_HINT_SHORT = "esc stop all";
116
+ /** Dim hint shown in the status strip while children are live (aboveEditor / tree). */
117
+ export const IDLE_STOP_HINT = "/legion stop [name|all]";
118
+ export const IDLE_STOP_HINT_SHORT = "/legion stop";
@@ -7,10 +7,10 @@ export const PLAYBOOK_NAME = "pi-legion-playbook";
7
7
  export const PARENT_PLAYBOOK = [
8
8
  "Pi Legion playbook (frozen — do not invent a launch recipe):",
9
9
  "1. Call the `subagent` tool immediately with the matching role from the roster in this prompt. Do not substitute bash/curl/web fetch for a search or research role.",
10
- ' One child: subagent({ agent: "<role>", task: "..." })',
10
+ ' One child: subagent({ agent: "<role>", task: "...", name?: "scout-a" }) // optional name= registers that handle (not auto scout-N)',
11
11
  " Parallel: subagent({ tasks: [{ agent, task }, { agent, task }] })",
12
12
  "2. Prefer tasks[] for independent parallel work. Child processes are headless RPC children; no pane or tab is created.",
13
- "3. Then return control. A completion message wakes this session when idle; if it is still working, the notice waits until the current turn ends. Use `wait` when you need blocking results, and `collect` to inspect a child's session JSONL. Pressing Esc to abort a parent turn gracefully retires its children; a wait interrupted by Esc returns `interrupted (parent turn aborted)` after cascade shutdown.",
13
+ "3. Then return control. A completion message wakes this session when idle; if it is still working, the notice waits until the current turn ends. Use `wait` when you need blocking results, and `collect` to inspect a child's session JSONL. Esc / wait interrupt only ends the parent turn's wait — children keep running and settle still notifies; only an explicit `stop` or session exit retires them. A wait interrupted by Esc returns `interrupted (parent turn ended; children still running)`. In `pi --print` or JSON mode, session shutdown waits for live async children to settle (up to each child's turn timeout) before retiring them. Use same-turn `wait`/`collect` or `async: false` when that print turn must include the child's result.",
14
14
  "4. Later control is only through `subagent({ action, name, message })`; use `stop` to gracefully retire one child or all tracked children. If a child exits oddly (Model not found, missing tools, memory refused, …), call `subagent({ action: \"diagnose\" })` or ask the user to run `/legion doctor` / `/legion doctor models` — children use `--no-extensions`, so provider packages must be passed explicitly. Do not tell a child to message or prompt the parent directly.",
15
15
  "5. A collect timeout with a live RPC child is a progress signal, not a verdict. Check status or session JSONL before deciding to steer or resume.",
16
16
  "6. Isolation is YOUR call. Pass `worktree: true` when another parent may write this repo or the child must ship via MR; pass `worktree: false` to share the checkout. Omit it for the role default.",
@@ -22,8 +22,8 @@ export const PARENT_PLAYBOOK = [
22
22
  export const TOOL_DESCRIPTION = [
23
23
  PARENT_PLAYBOOK,
24
24
  "Roles: the system prompt lists every loaded role. Prefer the matching role over doing that work yourself.",
25
- "Launch is async by default. Outcomes come from the child session JSONL.",
26
- "Actions: launch (default), continue, steer, resume, status, collect, wait, list, retire, stop, diagnose. `diagnose` prints the Legion doctor report (host vs child extensions/providers/models/memory). `wait` is event-driven: it returns when a child settles, times out, or the parent turn is aborted; an Esc abort also gracefully retires children owned by that turn. `stop` explicitly and gracefully retires selected children.",
25
+ "Launch is async by default. Outcomes come from the child session JSONL. In non-interactive `pi --print` / JSON mode, shutdown waits for those children before retiring them; pass `async: false` or call `wait`/`collect` in the same turn to fold the result into the print output.",
26
+ "Actions: launch (default), continue, steer, resume, status, collect, wait, list, retire, stop, diagnose. `diagnose` prints the Legion doctor report (host vs child extensions/providers/models/memory). `wait` is event-driven: it returns when a child settles, times out, or the parent turn is interrupted; Esc does not retire children — use `stop` (or exit the session) to retire them.",
27
27
  ].join(" ");
28
28
 
29
29
  /** Split shell commands into sequential chunks while preserving old helper API. */
@@ -30,7 +30,7 @@ import {
30
30
  } from "../tui/status.ts";
31
31
  import { progressFromSessionFile } from "../shared/progress.ts";
32
32
  import { isTransientConnectionFailure } from "../shared/session.ts";
33
- import { DEFAULTS, type AgentKind } from "../shared/types.ts";
33
+ import { DEFAULTS, type AgentKind, type RetireOptions } from "../shared/types.ts";
34
34
  import type {
35
35
  EventSubscriptionOptions,
36
36
  SupervisorEvent,
@@ -81,7 +81,7 @@ export interface TrackedJobInput {
81
81
  isAlive?: () => boolean;
82
82
  persist?: (snapshot: CollectSnapshot) => Promise<void>;
83
83
  /** Retire the RPC child after a terminal collect. Blocked children stay alive. */
84
- retire?: () => Promise<void>;
84
+ retire?: (opts?: RetireOptions) => Promise<void>;
85
85
  /**
86
86
  * Called when collect reports a blocked child. `resume` rewatches after
87
87
  * the parent approved or denied; `hold` keeps the widget up.
@@ -190,10 +190,35 @@ export function lastJsonlEventAgeMs(sessionFile: string, now: number): number |
190
190
  }
191
191
  }
192
192
 
193
+ /** Quiet age at or above this is treated as a long silent stretch (P2a). */
194
+ export const LONG_QUIET_MS = 30_000;
195
+
196
+ /**
197
+ * Lead line for a living child: process is up, session JSONL quiet for N seconds.
198
+ * `ageMs` undefined means the session file is missing or unreadable.
199
+ * Long quiet (>= {@link LONG_QUIET_MS}) adds `(living)` so 「活着但安静」reads first.
200
+ */
201
+ export function formatAliveQuiet(ageMs: number | undefined): string {
202
+ if (ageMs === undefined) return "alive · quiet unknown";
203
+ const secs = Math.floor(ageMs / 1000);
204
+ if (ageMs >= LONG_QUIET_MS) return `alive · quiet ${secs}s (living)`;
205
+ return `alive · quiet ${secs}s`;
206
+ }
207
+
208
+ /** @deprecated Prefer {@link formatAliveQuiet}; kept for older call sites. */
193
209
  export function formatLastEventAge(ageMs: number): string {
194
210
  return `last event ${Math.floor(ageMs / 1000)}s ago`;
195
211
  }
196
212
 
213
+ /**
214
+ * Collect-deadline / transient-connection / "still alive" reasons are progress
215
+ * signals, not verdicts. Showing them next to quiet age steals the eye.
216
+ */
217
+ export function isAliveProgressSignalReason(reason: string | undefined): boolean {
218
+ if (!reason) return false;
219
+ return /still alive|rpc child alive|transient connection/i.test(reason);
220
+ }
221
+
197
222
  function persistedSnapshotIsStale(input: LiveChildStatusInput): boolean {
198
223
  if (!input.alive) return false;
199
224
  if (input.state === "retired" || input.state === "exited") return true;
@@ -202,12 +227,27 @@ function persistedSnapshotIsStale(input: LiveChildStatusInput): boolean {
202
227
  return isTransientConnectionFailure(execution);
203
228
  }
204
229
 
230
+ function formatExecutionStatusLine(
231
+ alive: boolean,
232
+ execution: { status: string; reason?: string },
233
+ ): string {
234
+ const label = alive ? "execution snapshot" : "execution";
235
+ // Alive + progress-signal reason: status only — quiet age owns the liveness story.
236
+ if (alive && isAliveProgressSignalReason(execution.reason)) {
237
+ return `${label}: ${execution.status}`;
238
+ }
239
+ return `${label}: ${execution.status}${execution.reason ? ` (${execution.reason})` : ""}`;
240
+ }
241
+
205
242
  /**
206
- * Status text for one child.
243
+ * Status text for one child (`/legion status <name>` and `subagent status`).
207
244
  *
208
- * A living child always reports how long the session JSONL has been quiet.
209
- * A retired/failed record left behind by a connection blip is not the current
210
- * state while that process is still alive.
245
+ * Living children lead with `alive · quiet Ns` so a long silent reasoning
246
+ * stretch is obvious before any execution/acceptance snapshot. Progress-signal
247
+ * reasons (collect deadline, transient connection) are omitted from the
248
+ * snapshot line while alive so they do not steal attention from quiet age.
249
+ * A retired or failed record left by a connection blip is not treated as
250
+ * current state while the RPC process is still alive.
211
251
  */
212
252
  export function renderLiveChildStatus(input: LiveChildStatusInput): string {
213
253
  const stale = persistedSnapshotIsStale(input);
@@ -217,13 +257,18 @@ export function renderLiveChildStatus(input: LiveChildStatusInput): string {
217
257
  : input.execution;
218
258
  const lines = [
219
259
  `${input.name} — state=${state} agent=${input.agent ?? "?"} kind=${input.kind ?? "?"}`,
260
+ ];
261
+ if (input.alive) {
262
+ const age = lastJsonlEventAgeMs(input.sessionFile, input.now ?? Date.now());
263
+ lines.push(formatAliveQuiet(age));
264
+ }
265
+ // Quiet age first; path metadata after snapshots so old store lines stay below.
266
+ const meta = [
220
267
  "supervisor=rpc",
221
268
  `session=${input.sessionFile}`,
222
269
  ];
223
270
  if (execution) {
224
- lines.push(
225
- `execution: ${execution.status}${execution.reason ? ` (${execution.reason})` : ""}`,
226
- );
271
+ lines.push(formatExecutionStatusLine(input.alive, execution));
227
272
  if (execution.usage) {
228
273
  lines.push(
229
274
  `usage: in=${execution.usage.input} out=${execution.usage.output} cost=${execution.usage.cost.toFixed(4)}`,
@@ -231,14 +276,12 @@ export function renderLiveChildStatus(input: LiveChildStatusInput): string {
231
276
  }
232
277
  }
233
278
  if (!stale && input.acceptance) {
279
+ const acceptanceLabel = input.alive ? "acceptance snapshot" : "acceptance";
234
280
  lines.push(
235
- `acceptance: ${input.acceptance.status}${input.acceptance.level ? ` (${input.acceptance.level})` : ""}`,
281
+ `${acceptanceLabel}: ${input.acceptance.status}${input.acceptance.level ? ` (${input.acceptance.level})` : ""}`,
236
282
  );
237
283
  }
238
- if (input.alive) {
239
- const age = lastJsonlEventAgeMs(input.sessionFile, input.now ?? Date.now());
240
- if (age !== undefined) lines.push(formatLastEventAge(age));
241
- }
284
+ lines.push(...meta);
242
285
  if (stale) {
243
286
  const persistedExecution = input.execution
244
287
  ? `execution=${input.execution.status}${input.execution.reason ? ` (${input.execution.reason})` : ""}`
@@ -257,7 +300,7 @@ export interface WaitResult {
257
300
  snapshot?: CollectSnapshot;
258
301
  /** Still running when the timeout fired. */
259
302
  stillRunning?: boolean;
260
- /** The parent turn aborted while this wait was pending. */
303
+ /** The parent turn ended while this wait was pending; children still running. */
261
304
  interrupted?: boolean;
262
305
  /** Not tracked live and no finished cache (pre-validated upstream). */
263
306
  missing?: boolean;
@@ -296,8 +339,6 @@ export interface SessionRuntime {
296
339
  refreshUi(): void;
297
340
  /** Live visualization data (nodes + token-rate sampler) for the /legion view. */
298
341
  vizSnapshot(): StatusVizSnapshot;
299
- /** Main agent idle flag for the railway stop hint. */
300
- setParentIdle(idle: boolean): void;
301
342
  /** TUI hosting the status widget (unknown shape; used for overlay/focus checks). */
302
343
  statusTui(): unknown;
303
344
  /** Forward join config to the internal coordinator (settings wiring). */
@@ -310,6 +351,11 @@ export interface SessionRuntime {
310
351
  names: string[],
311
352
  opts?: { timeoutMs?: number; signal?: AbortSignal },
312
353
  ): Promise<WaitResult[]>;
354
+ /**
355
+ * Non-interactive shutdown: let in-flight collects finish before the
356
+ * caller retires whatever is still alive. Watches do not re-arm.
357
+ */
358
+ drainBeforeShutdown(): Promise<void>;
313
359
  dispose(): void;
314
360
  }
315
361
 
@@ -329,6 +375,8 @@ export function createSessionRuntime(deps: SessionRuntimeDeps): SessionRuntime {
329
375
  let dbEventSource: DbEventSource | undefined;
330
376
  let timer: ReturnType<typeof setInterval> | undefined;
331
377
  let disposed = false;
378
+ let draining = false;
379
+ let drainPromise: Promise<void> | undefined;
332
380
  let busyRaised = false;
333
381
 
334
382
  // Smart join: batch completion notices per runId. The deliver sink merges
@@ -659,9 +707,10 @@ export function createSessionRuntime(deps: SessionRuntimeDeps): SessionRuntime {
659
707
  }
660
708
  const outcome = await Promise.race(racers);
661
709
  if (outcome === WAIT_INTERRUPTED) {
662
- // The session-level abort cascade retires this job, so do not arm
663
- // a completion watcher for a child that is about to be closed.
710
+ // Esc / parent-turn abort does not retire children (esc-no-retire-v3).
711
+ // Re-arm watch so settle still notifies the parent.
664
712
  job.consumedByTool = false;
713
+ if (!job.watching) runtime.watch(name);
665
714
  return { name, interrupted: true };
666
715
  }
667
716
  const snapshot = outcome;
@@ -800,6 +849,15 @@ export function createSessionRuntime(deps: SessionRuntimeDeps): SessionRuntime {
800
849
  try {
801
850
  snapshot = await ensureCollect(job);
802
851
  if (disposed || job.generation !== gen) return;
852
+ // Print/json shutdown is waiting on this collect. A timeout or
853
+ // a blocked child must stay tracked so the caller can retire
854
+ // it; re-arming would start another turn after the process
855
+ // has already decided to exit.
856
+ if (draining && !shouldRecycleAfterCollect(snapshot.execution.status)) {
857
+ hold = true;
858
+ job.watching = false;
859
+ return;
860
+ }
803
861
  if (snapshot.blocked) {
804
862
  job.state = "blocked";
805
863
  refreshUi();
@@ -1006,9 +1064,6 @@ export function createSessionRuntime(deps: SessionRuntimeDeps): SessionRuntime {
1006
1064
  return [...jobs.values()];
1007
1065
  },
1008
1066
 
1009
- setParentIdle(idle) {
1010
- board.setIdleHint?.(idle);
1011
- },
1012
1067
  statusTui() {
1013
1068
  return board.getTui?.();
1014
1069
  },
@@ -1087,6 +1142,26 @@ export function createSessionRuntime(deps: SessionRuntimeDeps): SessionRuntime {
1087
1142
  );
1088
1143
  },
1089
1144
 
1145
+ drainBeforeShutdown() {
1146
+ if (disposed) return Promise.resolve();
1147
+ if (drainPromise) return drainPromise;
1148
+ draining = true;
1149
+ const pending = [...jobs.values()];
1150
+ drainPromise = (async () => {
1151
+ await Promise.all(pending.map(async (job) => {
1152
+ try {
1153
+ await ensureCollect(job);
1154
+ } catch {
1155
+ // The watch records the failure; shutdown still retires the child.
1156
+ }
1157
+ }));
1158
+ // The watch continuation shares this collect and must observe
1159
+ // `draining` before the caller snapshots the jobs it will retire.
1160
+ await new Promise<void>((resolve) => setImmediate(resolve));
1161
+ })();
1162
+ return drainPromise;
1163
+ },
1164
+
1090
1165
  dispose() {
1091
1166
  if (disposed) return;
1092
1167
  disposed = true;
@@ -3,8 +3,11 @@
3
3
  *
4
4
  * Counts consecutive read-like tool calls in the parent session. When the
5
5
  * streak reaches N with no subagent/vexillatio dispatch in between, inject
6
- * one follow-up reminder. The tool_call hook only updates state; the
7
- * reminder is scheduled so it cannot block the current tool call.
6
+ * one follow-up reminder. First-class read tools (`read`, `grep`, `find`,
7
+ * `ls`) count the same as read-only bash; playbooks prefer those over bash,
8
+ * so ignoring them let a main-session scout miss the threshold. The
9
+ * tool_call hook only updates state; the reminder is scheduled so it cannot
10
+ * block the current tool call.
8
11
  *
9
12
  * Children carry PI_SUBAGENT_ROLE and must not run this logic.
10
13
  */
@@ -74,6 +77,12 @@ const PACKAGE_WRITE = new Set([
74
77
  ]);
75
78
 
76
79
  const DISPATCH_TOOLS = new Set(["subagent", "vexillatio"]);
80
+ /**
81
+ * Pi built-in read-only tools (`createReadOnlyTools`): `read`, `grep`,
82
+ * `find`, `ls`. `find` here is the read-only search tool, not shell `find
83
+ * -delete` (that path is classified from bash).
84
+ */
85
+ const READ_TOOLS = new Set(["read", "grep", "find", "ls"]);
77
86
  const WRITE_TOOLS = new Set(["edit", "write"]);
78
87
 
79
88
  export function initialScoutGuardState(): ScoutGuardState {
@@ -132,7 +141,7 @@ export function classifyScoutToolCall(toolName: string, input: unknown): ScoutTo
132
141
  if (DISPATCH_TOOLS.has(toolName)) {
133
142
  return isDispatchCall(toolName, input) ? "dispatch" : "ignore";
134
143
  }
135
- if (toolName === "read") return "read";
144
+ if (READ_TOOLS.has(toolName)) return "read";
136
145
  if (WRITE_TOOLS.has(toolName)) return "write";
137
146
  if (toolName !== "bash") return "ignore";
138
147
  const command = record(input)?.command;
package/src/runs/args.ts CHANGED
@@ -76,6 +76,8 @@ export interface BuildArgsInput {
76
76
  sessionFile: string;
77
77
  /** Resolved model id, may already include a `:thinking` suffix. */
78
78
  model?: string;
79
+ /** Provider id for `--provider` when the resolved model id is ambiguous. */
80
+ modelProvider?: string;
79
81
  thinking?: string | false;
80
82
  /** Where to write an oversized task / system prompt. */
81
83
  tempDir: string;
@@ -128,7 +130,17 @@ export function buildPiArgs(input: BuildArgsInput): BuildArgsResult {
128
130
  /** `--model provider/id[:thinking]`, omitted when no model resolved. */
129
131
  function pushModelArgs(args: string[], input: BuildArgsInput): void {
130
132
  const modelArg = applyThinkingSuffix(input.model, input.thinking);
131
- if (modelArg) args.push("--model", modelArg);
133
+ if (!modelArg) return;
134
+ args.push("--model", modelArg);
135
+ // Pin the provider so a bare model id cannot be hijacked by a colliding
136
+ // static provider in models.json (#123: `workbuddy/...` ids also exist under
137
+ // a provider with no usable auth; under `--no-extensions` the child resolved
138
+ // them there and died with "No API key found"). Models may carry their own
139
+ // slash (e.g. `any-endpoint/workbuddy/hy4-preview-ioa`), so only the longest
140
+ // known provider prefix qualifies; an id with no provider stays bare, and
141
+ // `--provider` without `--model` is rejected by pi, so both move together.
142
+ const provider = input.modelProvider;
143
+ if (provider) args.push("--provider", provider);
132
144
  }
133
145
 
134
146
  export const CHILD_INHERIT_EXTENSIONS_ENV = "PI_LEGION_CHILD_INHERIT_EXTENSIONS";
@@ -152,6 +164,60 @@ export function childInheritsExtensions(agent: Pick<AgentConfig, "inheritExtensi
152
164
  return raw === "1" || raw === "true" || raw === "on" || raw === "yes";
153
165
  }
154
166
 
167
+ /** Light pi builtin that registers the `codemode` tool (`-e builtin:codemode`). */
168
+ export const BUILTIN_CODEMODE = "builtin:codemode";
169
+
170
+ /** True for `builtin:<name>` refs — must not be `path.resolve`d. */
171
+ export function isBuiltinExtensionRef(ext: string): boolean {
172
+ return ext.trimStart().startsWith("builtin:");
173
+ }
174
+
175
+ /** Dedup key for an `--extension` value (path or builtin ref). */
176
+ export function extensionDedupeKey(ext: string): string {
177
+ return isBuiltinExtensionRef(ext) ? ext.trim() : path.resolve(ext);
178
+ }
179
+
180
+ export function roleListsCodemode(agent: Pick<AgentConfig, "tools">): boolean {
181
+ return (agent.tools ?? []).some((t) => t === "codemode");
182
+ }
183
+
184
+ export function isCodemodeExtension(ext: string): boolean {
185
+ const t = ext.trim();
186
+ return t === BUILTIN_CODEMODE || t.endsWith("/" + BUILTIN_CODEMODE);
187
+ }
188
+
189
+ /**
190
+ * Effective `--extension` values for a child under `--no-extensions`.
191
+ *
192
+ * Includes role `extensions` / `subagentOnlyExtensions`, `PI_LEGION_CHILD_EXTENSIONS`,
193
+ * auto `builtin:codemode` when `tools` lists `codemode` (#89), and the child-guard.
194
+ */
195
+ export function collectChildExtensions(
196
+ agent: Pick<AgentConfig, "tools" | "extensions" | "subagentOnlyExtensions">,
197
+ env: NodeJS.ProcessEnv = process.env,
198
+ ): string[] {
199
+ const list = [
200
+ ...(agent.extensions ?? []),
201
+ ...(agent.subagentOnlyExtensions ?? []),
202
+ ...childExtraExtensions(env),
203
+ ];
204
+ // #89: codemode is a light builtin (unlike heavy pi-lens). Keep tools:codemode
205
+ // honest when discovery is disabled via `--no-extensions`.
206
+ if (roleListsCodemode(agent) && !list.some(isCodemodeExtension)) {
207
+ list.push(BUILTIN_CODEMODE);
208
+ }
209
+ list.push(childGuardExtensionPath());
210
+ const seen = new Set<string>();
211
+ const out: string[] = [];
212
+ for (const ext of list) {
213
+ const key = extensionDedupeKey(ext);
214
+ if (seen.has(key)) continue;
215
+ seen.add(key);
216
+ out.push(ext);
217
+ }
218
+ return out;
219
+ }
220
+
155
221
  /** `--tools a,b`, `--no-extensions` (unless inherited), plus one `--extension` per explicit extension. */
156
222
  function pushToolArgs(args: string[], agent: AgentConfig): void {
157
223
  const tools = agent.tools?.filter(Boolean) ?? [];
@@ -164,19 +230,9 @@ function pushToolArgs(args: string[], agent: AgentConfig): void {
164
230
  // silently inherit every extension the parent happens to have loaded
165
231
  // (#43: each host extension such as an LSP stack costs ~1 GB per child).
166
232
  // `--no-extensions` stops discovery; explicit `--extension` paths below
167
- // (the role's list + this package for the guard / nested spawning) still load.
233
+ // (the role's list + auto builtin:codemode + this package for the guard) still load.
168
234
  if (!childInheritsExtensions(agent)) args.push("--no-extensions");
169
- const extensions = [
170
- ...(agent.extensions ?? []),
171
- ...(agent.subagentOnlyExtensions ?? []),
172
- ...childExtraExtensions(),
173
- childGuardExtensionPath(),
174
- ];
175
- const seen = new Set<string>();
176
- for (const ext of extensions) {
177
- const resolved = path.resolve(ext);
178
- if (seen.has(resolved)) continue;
179
- seen.add(resolved);
235
+ for (const ext of collectChildExtensions(agent)) {
180
236
  args.push("--extension", ext);
181
237
  }
182
238
  }