pi-legion 2.0.0 → 2.0.1
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 +117 -0
- package/CONTRIBUTING.md +51 -18
- package/README.md +93 -7
- package/agents/planner.md +1 -1
- package/agents/reviewer.md +2 -3
- package/agents/worker.md +2 -3
- package/docs/contracts/esc-cascade-v2.md +2 -1
- package/docs/design.md +33 -16
- package/docs/vexillatio-contract.md +1 -1
- package/index.ts +125 -21
- 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 +63 -9
- package/src/extension/legion-doctor.ts +127 -40
- package/src/extension/legion-status.ts +172 -0
- package/src/extension/playbook.ts +3 -3
- package/src/extension/runtime.ts +93 -14
- package/src/extension/scout-guard.ts +12 -3
- package/src/runs/args.ts +69 -13
- package/src/runs/orchestrator.ts +22 -2
- package/src/runs/provider-pin.ts +59 -0
- package/src/runs/role-tool-gates.ts +103 -0
- package/src/shared/session.ts +37 -2
- package/src/shared/types.ts +8 -0
- package/src/tui/viz/panels.ts +3 -1
|
@@ -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
|
}
|
package/src/runs/orchestrator.ts
CHANGED
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
import { canUseCachedCollect } from "../extension/recycle.ts";
|
|
32
32
|
import {
|
|
33
33
|
countAssistantMessages,
|
|
34
|
+
attributeParentSessionShutdown,
|
|
34
35
|
deriveOutcome,
|
|
35
36
|
emptyParsedSession,
|
|
36
37
|
extractVerdict,
|
|
@@ -51,6 +52,7 @@ import {
|
|
|
51
52
|
MAX_NESTED_PATH_ENTRIES,
|
|
52
53
|
type ModelOrigin,
|
|
53
54
|
type NestedPathEntry,
|
|
55
|
+
type RetireOptions,
|
|
54
56
|
type RunRecord,
|
|
55
57
|
SubagentError,
|
|
56
58
|
type Usage,
|
|
@@ -239,6 +241,8 @@ export class Orchestrator {
|
|
|
239
241
|
private readonly children = new Map<string, ChildRecord>();
|
|
240
242
|
private readonly nameCounter = new Map<string, number>();
|
|
241
243
|
private readonly pendingWaits = new Map<string, Promise<SettleResult>>();
|
|
244
|
+
/** Children retired because the parent session is shutting down. */
|
|
245
|
+
private readonly parentShutdownRetire = new Set<string>();
|
|
242
246
|
|
|
243
247
|
constructor(deps: OrchestratorDeps) {
|
|
244
248
|
this.supervisor = deps.supervisor ?? new RpcSupervisor();
|
|
@@ -491,6 +495,8 @@ export class Orchestrator {
|
|
|
491
495
|
name?: string;
|
|
492
496
|
team?: string;
|
|
493
497
|
model?: string;
|
|
498
|
+
/** Provider id pinning `--provider` for an ambiguous bare model id (#123). */
|
|
499
|
+
modelProvider?: string;
|
|
494
500
|
thinking?: string | false;
|
|
495
501
|
worktree?: boolean;
|
|
496
502
|
modelOrigin?: ModelOrigin;
|
|
@@ -598,6 +604,7 @@ export class Orchestrator {
|
|
|
598
604
|
cwd: worktreePath ?? this.cwd,
|
|
599
605
|
env: childEnv,
|
|
600
606
|
...(model ? { model } : {}),
|
|
607
|
+
...(input.modelProvider ? { modelProvider: input.modelProvider } : {}),
|
|
601
608
|
...(input.stripLegionIdentity ? { stripLegionIdentity: true } : {}),
|
|
602
609
|
...(input.thinking !== undefined ? { thinking: input.thinking } : input.agent.thinking !== undefined ? { thinking: input.agent.thinking } : {}),
|
|
603
610
|
...(input.agent.allowNestedSubagents !== undefined && !input.agent.utility ? { allowNestedSubagents: input.agent.allowNestedSubagents } : {}),
|
|
@@ -860,6 +867,11 @@ export class Orchestrator {
|
|
|
860
867
|
execution = { status: "aborted", reason: "RPC child exited before writing session JSONL", model: child.model ?? null };
|
|
861
868
|
}
|
|
862
869
|
}
|
|
870
|
+
// A parent shutdown can resolve the in-flight wait by killing the child.
|
|
871
|
+
// That is not the child settling a turn with no assistant reply.
|
|
872
|
+
if (this.parentShutdownRetire.has(name) && wait !== "settled") {
|
|
873
|
+
execution = attributeParentSessionShutdown(execution, parsed);
|
|
874
|
+
}
|
|
863
875
|
return this.finishCollect(child, parsed, execution, deadline);
|
|
864
876
|
}
|
|
865
877
|
|
|
@@ -1098,10 +1110,18 @@ export class Orchestrator {
|
|
|
1098
1110
|
return cached;
|
|
1099
1111
|
}
|
|
1100
1112
|
|
|
1101
|
-
async retire(name: string): Promise<ChildRecord> {
|
|
1113
|
+
async retire(name: string, opts?: RetireOptions): Promise<ChildRecord> {
|
|
1102
1114
|
const child = this.requireChild(name);
|
|
1115
|
+
if (opts?.cause === "session-shutdown") this.parentShutdownRetire.add(name);
|
|
1103
1116
|
if (child.state === "retired") return child;
|
|
1104
|
-
|
|
1117
|
+
const sessionExists = fs.existsSync(child.sessionFile);
|
|
1118
|
+
const parsed = sessionExists ? parseSessionFile(child.sessionFile) : emptyParsedSession();
|
|
1119
|
+
if (!child.execution && sessionExists) child.execution = deriveOutcome(parsed);
|
|
1120
|
+
// Stamp the cause before killing so an in-flight collect cannot
|
|
1121
|
+
// overwrite it with "no assistant message" after the process dies.
|
|
1122
|
+
if (opts?.cause === "session-shutdown" && this.supervisor.isAlive(name)) {
|
|
1123
|
+
child.execution = attributeParentSessionShutdown(child.execution ?? deriveOutcome(parsed), parsed);
|
|
1124
|
+
}
|
|
1105
1125
|
if (this.supervisor.isAlive(name)) await this.supervisor.retire(name);
|
|
1106
1126
|
if (child.nodeId && this.legion) {
|
|
1107
1127
|
if (child.execution && child.execution.status !== "unknown" && child.execution.status !== "running") {
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider pinning for ambiguous bare model ids (#123).
|
|
3
|
+
*
|
|
4
|
+
* A model id like `workbuddy/hy4-preview-ioa` can exist under more than one
|
|
5
|
+
* provider: one registered live by an extension (any-endpoint) and one static
|
|
6
|
+
* in models.json without usable auth. Children start with `--no-extensions`,
|
|
7
|
+
* so the live provider only exists when the role lists its extension — and a
|
|
8
|
+
* bare `--model workbuddy/...` still resolves into the FIRST provider that
|
|
9
|
+
* declares the id, which may be the auth-less static one. The child then dies
|
|
10
|
+
* instantly with "No API key found for <provider>".
|
|
11
|
+
*
|
|
12
|
+
* Fix: at dispatch time, ask the PARENT's registry which provider actually
|
|
13
|
+
* owns the resolved model and pass it as `--provider` alongside `--model`.
|
|
14
|
+
* The parent registry reflects the live extension providers, so the pinned
|
|
15
|
+
* provider is the one that can authenticate.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** Minimal registry surface `providerForModel` needs (subset of ModelRegistry). */
|
|
19
|
+
export interface ProviderLookup {
|
|
20
|
+
getAll(): Array<{ provider: string; id: string }>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Find the provider that owns `model` in the parent's registry.
|
|
25
|
+
*
|
|
26
|
+
* Returns `undefined` when the model cannot be found or when more than one
|
|
27
|
+
* provider declares the same id AND the id carries no provider prefix — in
|
|
28
|
+
* that ambiguous case pinning a wrong provider is worse than leaving the
|
|
29
|
+
* child to resolve it. Models with a `provider/id` prefix are matched on the
|
|
30
|
+
* full prefixed id first (extension providers register such ids verbatim),
|
|
31
|
+
* then on the suffix after the first slash.
|
|
32
|
+
*/
|
|
33
|
+
export function providerForModel(
|
|
34
|
+
registry: ProviderLookup | undefined,
|
|
35
|
+
model: string | undefined,
|
|
36
|
+
): string | undefined {
|
|
37
|
+
if (!registry || !model) return undefined;
|
|
38
|
+
const bare = model.trim();
|
|
39
|
+
if (!bare) return undefined;
|
|
40
|
+
|
|
41
|
+
const models = registry.getAll();
|
|
42
|
+
// Candidates: the id verbatim (extension providers may register prefixed
|
|
43
|
+
// ids) and, when `bare` carries a provider prefix, the id after stripping it
|
|
44
|
+
// (`any-endpoint/workbuddy/x` ↔ registry id `workbuddy/x`).
|
|
45
|
+
const suffix = bare.includes("/") ? bare.slice(bare.indexOf("/") + 1) : undefined;
|
|
46
|
+
const matches = models.filter(
|
|
47
|
+
(m) => m.id === bare || (suffix !== undefined && m.id === suffix),
|
|
48
|
+
);
|
|
49
|
+
if (matches.length === 1) {
|
|
50
|
+
return matches[0]!.provider;
|
|
51
|
+
}
|
|
52
|
+
if (matches.length > 1) {
|
|
53
|
+
// Prefer the provider named by the id's own prefix (`any-endpoint/...`).
|
|
54
|
+
const prefix = bare.split("/")[0]!;
|
|
55
|
+
const prefixed = matches.find((m) => m.provider === prefix);
|
|
56
|
+
return prefixed?.provider;
|
|
57
|
+
}
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extensible doctor gates: role `tools:` that need an extension under
|
|
3
|
+
* `--no-extensions` (#70 lens, #89/#97 codemode, future builtins).
|
|
4
|
+
*
|
|
5
|
+
* Add a row here when a new tool would otherwise false-promise under
|
|
6
|
+
* `--no-extensions`. Keep stable `code` values when tests/docs depend on them.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { CHILD_EXTENSIONS_ENV, BUILTIN_CODEMODE, isCodemodeExtension } from "./args.ts";
|
|
10
|
+
import type { AgentConfig } from "../shared/types.ts";
|
|
11
|
+
|
|
12
|
+
export interface RoleToolExtensionGate {
|
|
13
|
+
/** Stable issue code (lens/codemode keep specific codes for back-compat). */
|
|
14
|
+
code: string;
|
|
15
|
+
/** Match against role `tools:` entries. */
|
|
16
|
+
matchesTools: (tools: readonly string[]) => boolean;
|
|
17
|
+
/** True when the effective child extension list satisfies the requirement. */
|
|
18
|
+
extensionsSatisfy: (exts: readonly string[]) => boolean;
|
|
19
|
+
toolLabel: string;
|
|
20
|
+
extensionLabel: string;
|
|
21
|
+
fix: (roleName: string) => string;
|
|
22
|
+
nextStep: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Configurable tool → extension requirements for `/legion doctor` (#97).
|
|
27
|
+
*
|
|
28
|
+
* Order is the issue emission order. Prefer specific codes over a single
|
|
29
|
+
* `ROLE_TOOL_UNAVAILABLE` so Next steps and existing tests stay precise.
|
|
30
|
+
*/
|
|
31
|
+
export const ROLE_TOOL_EXTENSION_GATES: readonly RoleToolExtensionGate[] = [
|
|
32
|
+
{
|
|
33
|
+
code: "ROLE_MISSING_LENS",
|
|
34
|
+
matchesTools: (tools) =>
|
|
35
|
+
tools.some((t) =>
|
|
36
|
+
/lens|symbol_search|project_report|module_report|read_symbol|read_enclosing/i.test(t),
|
|
37
|
+
),
|
|
38
|
+
extensionsSatisfy: (exts) => exts.some((e) => /pi-lens/i.test(e)),
|
|
39
|
+
toolLabel: "lens tools",
|
|
40
|
+
extensionLabel: "pi-lens",
|
|
41
|
+
fix: (roleName) =>
|
|
42
|
+
`Add pi-lens to ${roleName}'s extensions: frontmatter, or to ${CHILD_EXTENSIONS_ENV}.`,
|
|
43
|
+
nextStep:
|
|
44
|
+
"Add pi-lens extension path to roles that list lens_* tools, or to PI_LEGION_CHILD_EXTENSIONS.",
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
code: "ROLE_MISSING_CODEMODE",
|
|
48
|
+
matchesTools: (tools) => tools.some((t) => t === "codemode"),
|
|
49
|
+
extensionsSatisfy: (exts) => exts.some(isCodemodeExtension),
|
|
50
|
+
toolLabel: "codemode",
|
|
51
|
+
extensionLabel: BUILTIN_CODEMODE,
|
|
52
|
+
fix: (roleName) =>
|
|
53
|
+
`Add ${BUILTIN_CODEMODE} to ${roleName}'s extensions: frontmatter, or ensure tools: includes codemode so Legion auto-loads it.`,
|
|
54
|
+
nextStep: `Add ${BUILTIN_CODEMODE} to roles that list codemode (or keep tools: codemode so Legion auto-loads it).`,
|
|
55
|
+
},
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
/** Plain issue shape so this module stays free of doctor imports. */
|
|
59
|
+
export interface RoleToolGateIssue {
|
|
60
|
+
severity: "warning";
|
|
61
|
+
code: string;
|
|
62
|
+
section: "extensions";
|
|
63
|
+
message: string;
|
|
64
|
+
fix: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Collect role-tool / extension mismatch issues for one agent.
|
|
69
|
+
*
|
|
70
|
+
* `effectiveExts` should be the same list the child argv would load
|
|
71
|
+
* (e.g. `collectChildExtensions`). When `inheritsExtensions` is true the
|
|
72
|
+
* child loads host discovery — gates do not fire.
|
|
73
|
+
*/
|
|
74
|
+
export function collectRoleToolExtensionIssues(
|
|
75
|
+
agent: Pick<AgentConfig, "name" | "tools">,
|
|
76
|
+
effectiveExts: readonly string[],
|
|
77
|
+
opts: { inheritsExtensions: boolean },
|
|
78
|
+
): RoleToolGateIssue[] {
|
|
79
|
+
if (opts.inheritsExtensions) return [];
|
|
80
|
+
const tools = agent.tools ?? [];
|
|
81
|
+
const out: RoleToolGateIssue[] = [];
|
|
82
|
+
for (const gate of ROLE_TOOL_EXTENSION_GATES) {
|
|
83
|
+
if (!gate.matchesTools(tools)) continue;
|
|
84
|
+
if (gate.extensionsSatisfy(effectiveExts)) continue;
|
|
85
|
+
out.push({
|
|
86
|
+
severity: "warning",
|
|
87
|
+
code: gate.code,
|
|
88
|
+
section: "extensions",
|
|
89
|
+
message: `Role "${agent.name}" lists ${gate.toolLabel} but would not load ${gate.extensionLabel} under --no-extensions.`,
|
|
90
|
+
fix: gate.fix(agent.name),
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Next-step lines for any gate codes present in `issues`. */
|
|
97
|
+
export function roleToolGateNextSteps(issues: readonly { code: string }[]): string[] {
|
|
98
|
+
const steps: string[] = [];
|
|
99
|
+
for (const gate of ROLE_TOOL_EXTENSION_GATES) {
|
|
100
|
+
if (issues.some((i) => i.code === gate.code)) steps.push(gate.nextStep);
|
|
101
|
+
}
|
|
102
|
+
return steps;
|
|
103
|
+
}
|
package/src/shared/session.ts
CHANGED
|
@@ -513,6 +513,40 @@ export function isTransientConnectionFailure(
|
|
|
513
513
|
return /connection error/i.test(text);
|
|
514
514
|
}
|
|
515
515
|
|
|
516
|
+
/**
|
|
517
|
+
* Session ended with a user prompt and no assistant reply.
|
|
518
|
+
* This is a model/stream failure when the child finished on its own.
|
|
519
|
+
* A parent `session_shutdown` that kills the child first must not reuse it;
|
|
520
|
+
* see `attributeParentSessionShutdown`.
|
|
521
|
+
*/
|
|
522
|
+
export const NO_ASSISTANT_ABORT_REASON = "no assistant message for the last prompt";
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* Parent process shutdown retired the child before any assistant message
|
|
526
|
+
* was written. Distinct from {@link NO_ASSISTANT_ABORT_REASON}.
|
|
527
|
+
*/
|
|
528
|
+
export const PARENT_SESSION_SHUTDOWN_ABORT_REASON =
|
|
529
|
+
"parent session shutdown retired the child before it produced an assistant message";
|
|
530
|
+
|
|
531
|
+
/** True when the last turn (or the whole file) has no assistant message. */
|
|
532
|
+
export function lastTurnHasNoAssistant(parsed: ParsedSession): boolean {
|
|
533
|
+
const last = parsed.turns.at(-1);
|
|
534
|
+
return parsed.turns.length === 0 || !last || last.assistants.length === 0;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* Rewrite a missing-assistant abort when the parent session shut down and
|
|
539
|
+
* retired the child. A turn the child actually settled keeps `deriveOutcome`.
|
|
540
|
+
*/
|
|
541
|
+
export function attributeParentSessionShutdown(execution: Execution, parsed: ParsedSession): Execution {
|
|
542
|
+
if (execution.status === "success" || !lastTurnHasNoAssistant(parsed)) return execution;
|
|
543
|
+
return {
|
|
544
|
+
...execution,
|
|
545
|
+
status: "aborted",
|
|
546
|
+
reason: PARENT_SESSION_SHUTDOWN_ABORT_REASON,
|
|
547
|
+
};
|
|
548
|
+
}
|
|
549
|
+
|
|
516
550
|
/**
|
|
517
551
|
* Derive the execution outcome from a parsed session.
|
|
518
552
|
*
|
|
@@ -542,10 +576,11 @@ export function deriveOutcome(parsed: ParsedSession): Execution {
|
|
|
542
576
|
}
|
|
543
577
|
|
|
544
578
|
// F29: hard kill / interrupt before any reply was persisted.
|
|
579
|
+
// Parent-session cascade shutdown rewrites this in the orchestrator.
|
|
545
580
|
if (last.assistants.length === 0) {
|
|
546
581
|
return {
|
|
547
582
|
status: "aborted",
|
|
548
|
-
reason:
|
|
583
|
+
reason: NO_ASSISTANT_ABORT_REASON,
|
|
549
584
|
turns: turns.length,
|
|
550
585
|
toolErrors: parsed.toolErrors,
|
|
551
586
|
lastTurn: { stopReason: null, toolErrors: last.toolErrors },
|
|
@@ -558,7 +593,7 @@ export function deriveOutcome(parsed: ParsedSession): Execution {
|
|
|
558
593
|
if (!final) {
|
|
559
594
|
return {
|
|
560
595
|
status: "aborted",
|
|
561
|
-
reason:
|
|
596
|
+
reason: NO_ASSISTANT_ABORT_REASON,
|
|
562
597
|
turns: turns.length,
|
|
563
598
|
toolErrors: parsed.toolErrors,
|
|
564
599
|
};
|
package/src/shared/types.ts
CHANGED
|
@@ -246,6 +246,14 @@ export interface Execution {
|
|
|
246
246
|
completedAt?: string;
|
|
247
247
|
}
|
|
248
248
|
|
|
249
|
+
/**
|
|
250
|
+
* Why a still-live child is being retired.
|
|
251
|
+
* `session-shutdown` is the parent process leaving, not a model failure.
|
|
252
|
+
*/
|
|
253
|
+
export interface RetireOptions {
|
|
254
|
+
cause?: "session-shutdown";
|
|
255
|
+
}
|
|
256
|
+
|
|
249
257
|
export interface Usage {
|
|
250
258
|
input: number;
|
|
251
259
|
output: number;
|
package/src/tui/viz/panels.ts
CHANGED
|
@@ -252,7 +252,9 @@ export function drawGalaxy(cv: Canvas, r: Rect, data: ViewData, selectedId: stri
|
|
|
252
252
|
cv.text(lx + 2, ly, role, "dim");
|
|
253
253
|
lx += w;
|
|
254
254
|
}
|
|
255
|
-
|
|
255
|
+
const statuses = new Set(nodes.map((node) => node.status));
|
|
256
|
+
for (const [status, label, col] of [["failed", "✗ failed", "error"], ["retired", "⣿ retired", "dim"]] as const) {
|
|
257
|
+
if (!statuses.has(status)) continue;
|
|
256
258
|
if (lx + label.length + 1 > r.x + r.w - 1) break;
|
|
257
259
|
cv.text(lx, ly, label, col);
|
|
258
260
|
lx += label.length + 2;
|