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.
@@ -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
  }
@@ -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
- if (!child.execution && fs.existsSync(child.sessionFile)) child.execution = deriveOutcome(parseSessionFile(child.sessionFile));
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
+ }
@@ -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: "no assistant message for the last prompt",
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: "no assistant message for the last prompt",
596
+ reason: NO_ASSISTANT_ABORT_REASON,
562
597
  turns: turns.length,
563
598
  toolErrors: parsed.toolErrors,
564
599
  };
@@ -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;
@@ -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
- for (const [label, col] of [["✗ failed", "error"], ["⣿ retired", "dim"]] as const) {
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;