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.
- package/CHANGELOG.md +136 -0
- package/CONTRIBUTING.md +51 -18
- package/README.md +101 -18
- package/agents/planner.md +1 -1
- package/agents/reviewer.md +2 -3
- package/agents/worker.md +2 -3
- package/docs/contracts/esc-abort-v1.md +2 -0
- package/docs/contracts/esc-cascade-v2.md +5 -1
- package/docs/contracts/esc-no-retire-v3.md +57 -0
- package/docs/design.md +37 -18
- package/docs/vexillatio-contract.md +1 -1
- package/index.ts +145 -134
- package/package.json +8 -4
- package/skills/pi-legion/SKILL.md +22 -8
- package/src/agents/agents.ts +60 -5
- package/src/api.ts +2 -0
- package/src/extension/legion-commands.ts +64 -10
- package/src/extension/legion-doctor.ts +128 -41
- package/src/extension/legion-status.ts +172 -0
- package/src/extension/legion-stop.ts +5 -37
- package/src/extension/playbook.ts +4 -4
- package/src/extension/runtime.ts +97 -22
- package/src/extension/scout-guard.ts +12 -3
- package/src/runs/args.ts +69 -13
- package/src/runs/orchestrator.ts +28 -3
- package/src/runs/provider-pin.ts +59 -0
- package/src/runs/provision.ts +7 -4
- package/src/runs/role-tool-gates.ts +103 -0
- package/src/runs/worktree.ts +32 -0
- package/src/shared/session.ts +37 -2
- package/src/shared/types.ts +8 -0
- package/src/tui/status.ts +12 -14
- package/src/tui/viz/canvas.ts +5 -0
- package/src/tui/viz/panels.ts +16 -8
- package/src/tui/viz/railway.ts +34 -11
- package/src/tui/viz/rate.ts +41 -9
- package/src/tui/viz/view.ts +18 -11
|
@@ -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
|
-
*
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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.
|
|
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
|
|
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. */
|
package/src/extension/runtime.ts
CHANGED
|
@@ -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
|
-
*
|
|
209
|
-
*
|
|
210
|
-
*
|
|
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
|
-
|
|
281
|
+
`${acceptanceLabel}: ${input.acceptance.status}${input.acceptance.level ? ` (${input.acceptance.level})` : ""}`,
|
|
236
282
|
);
|
|
237
283
|
}
|
|
238
|
-
|
|
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
|
|
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
|
-
//
|
|
663
|
-
//
|
|
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.
|
|
7
|
-
*
|
|
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
|
|
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)
|
|
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
|
|
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
|
|
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
|
}
|