pi-legion 3.0.3 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/CHANGELOG.md +263 -2
  2. package/CONTRIBUTING.md +4 -17
  3. package/README.md +29 -22
  4. package/agents/worker.md +1 -1
  5. package/docs/contracts/display-liveness-v1.md +373 -0
  6. package/docs/contracts/drop-non-pi-kinds-v4.md +198 -0
  7. package/docs/contracts/esc-abort-v1.md +4 -4
  8. package/docs/contracts/esc-no-retire-v3.md +2 -2
  9. package/docs/contracts/launch-admission-queue-v1.md +839 -0
  10. package/docs/contracts/status-panel-v1.md +174 -0
  11. package/docs/inprocess-supervisor.md +37 -4
  12. package/docs/legion-v2-contract.md +18 -2
  13. package/docs/performance-2026-10-19.md +199 -0
  14. package/docs/spawn-mode-visibility-v1.md +206 -0
  15. package/docs/vexillatio-contract.md +3 -1
  16. package/index.ts +633 -84
  17. package/package.json +4 -2
  18. package/skills/pi-legion/SKILL.md +2 -3
  19. package/src/agents/agents.ts +17 -10
  20. package/src/agents/overrides.ts +0 -1
  21. package/src/agents/presets.ts +14 -40
  22. package/src/agents/settings.ts +26 -77
  23. package/src/agents/step-model.ts +7 -22
  24. package/src/api.ts +1 -35
  25. package/src/extension/event-bus.ts +80 -8
  26. package/src/extension/legion-commands.ts +43 -17
  27. package/src/extension/legion-doctor.ts +1 -1
  28. package/src/extension/legion-stop.ts +39 -13
  29. package/src/extension/legion-tools.ts +21 -4
  30. package/src/extension/liveness.ts +200 -0
  31. package/src/extension/notify.ts +6 -6
  32. package/src/extension/playbook.ts +6 -6
  33. package/src/extension/recycle.ts +6 -2
  34. package/src/extension/runtime.ts +422 -18
  35. package/src/extension/slash.ts +0 -21
  36. package/src/extension/spawn-render.ts +183 -0
  37. package/src/extension/summary.ts +1 -1
  38. package/src/legion/db.ts +165 -1
  39. package/src/legion/events.ts +81 -16
  40. package/src/legion/nodes.ts +13 -2
  41. package/src/legion/rebuild.ts +7 -1
  42. package/src/legion/usage.ts +58 -4
  43. package/src/runs/orchestrator.ts +1165 -62
  44. package/src/runs/worktree.ts +190 -21
  45. package/src/shared/ignored-kind.ts +57 -0
  46. package/src/shared/progress.ts +11 -0
  47. package/src/shared/session.ts +199 -19
  48. package/src/shared/types.ts +50 -66
  49. package/src/supervisor/admission-queue.ts +516 -0
  50. package/src/supervisor/inprocess-supervisor.ts +108 -33
  51. package/src/supervisor/proc-memory.ts +67 -0
  52. package/src/supervisor/routing-supervisor.ts +58 -1
  53. package/src/tui/live-overlay.ts +14 -0
  54. package/src/tui/session-messages-file.ts +100 -0
  55. package/src/tui/status.ts +103 -21
  56. package/src/tui/tree-view.ts +36 -3
  57. package/src/tui/viz/layout.ts +54 -3
  58. package/src/tui/viz/model.ts +67 -14
  59. package/src/tui/viz/palette.ts +11 -0
  60. package/src/tui/viz/panels.ts +222 -8
  61. package/src/tui/viz/railway.ts +28 -25
  62. package/src/tui/viz/view.ts +110 -23
  63. package/src/backends/legacy/client.ts +0 -638
  64. package/src/backends/legacy/cursor-chat.ts +0 -348
  65. package/src/backends/legacy/runner.ts +0 -101
  66. package/src/backends/legacy/types.ts +0 -138
  67. package/src/runs/kind.ts +0 -260
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-legion",
3
- "version": "3.0.3",
3
+ "version": "4.0.0",
4
4
  "description": "Pi Legion v2: recursive centurion trees on headless Pi RPC; shared SQLite WAL legion.db. Four-stage planning/implementing/reviewing/verifying state machine, parent/child/sibling legion_mail, three-layer /legion UI, upward MR trees, squad resume.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -54,9 +54,11 @@
54
54
  "test:integration": "env -u PI_SUBAGENT_ROLE -u PI_SUBAGENT_ACCEPTANCE_ROLE -u PI_SUBAGENT_ALLOW_NESTED -u PI_SUBAGENT_CHILD -u PI_SUBAGENT_UTILITY_CHILD -u PI_SUBAGENT_MAX_DEPTH -u PI_SUBAGENT_PARENT_PATH -u PI_LEGION_DB -u PI_LEGION_NODE_ID node --experimental-strip-types --test test/integration/*.test.ts",
55
55
  "test:live": "env -u PI_SUBAGENT_ROLE -u PI_SUBAGENT_ACCEPTANCE_ROLE -u PI_SUBAGENT_ALLOW_NESTED -u PI_SUBAGENT_CHILD -u PI_SUBAGENT_UTILITY_CHILD -u PI_SUBAGENT_MAX_DEPTH -u PI_SUBAGENT_PARENT_PATH -u PI_LEGION_DB -u PI_LEGION_NODE_ID node --experimental-strip-types --test test/live/*.test.ts",
56
56
  "test:all": "node --run typecheck && node --run test && node --run test:integration",
57
- "test:docker": "env -u PI_SUBAGENT_ROLE -u PI_SUBAGENT_ACCEPTANCE_ROLE -u PI_SUBAGENT_ALLOW_NESTED -u PI_SUBAGENT_CHILD -u PI_SUBAGENT_UTILITY_CHILD -u PI_SUBAGENT_MAX_DEPTH -u PI_SUBAGENT_PARENT_PATH sh -c 'echo \"RPC mainline Docker checks only; legacy Herdr workspace/kind/notification scenarios are archived under test/legacy/docker and require pi-legion v0.16.x\" && bash test/docker/verify-1-child-guard.sh && bash test/docker/verify-2-task-appendix.sh && bash test/docker/verify-3-readonly.sh && bash test/docker/verify-4-acceptance.sh && bash test/docker/verify-5-blocked.sh && bash test/docker/verify-6-cache.sh && bash test/docker/verify-7-budget.sh && bash test/docker/verify-8-verify-command.sh && bash test/docker/verify-9-worktree.sh && bash test/docker/verify-10-fallback.sh && bash test/docker/verify-11-nested-alias.sh'",
57
+ "test:docker": "env -u PI_SUBAGENT_ROLE -u PI_SUBAGENT_ACCEPTANCE_ROLE -u PI_SUBAGENT_ALLOW_NESTED -u PI_SUBAGENT_CHILD -u PI_SUBAGENT_UTILITY_CHILD -u PI_SUBAGENT_MAX_DEPTH -u PI_SUBAGENT_PARENT_PATH sh -c 'echo \"Docker mainline checks for the RPC path only\" && bash test/docker/verify-1-child-guard.sh && bash test/docker/verify-2-task-appendix.sh && bash test/docker/verify-3-readonly.sh && bash test/docker/verify-4-acceptance.sh && bash test/docker/verify-5-blocked.sh && bash test/docker/verify-6-cache.sh && bash test/docker/verify-7-budget.sh && bash test/docker/verify-8-verify-command.sh && bash test/docker/verify-9-worktree.sh && bash test/docker/verify-10-fallback.sh && bash test/docker/verify-11-nested-alias.sh'",
58
58
  "prepublishOnly": "node --run typecheck && node --run test && node --run test:integration",
59
59
  "mock-llm": "node --experimental-strip-types test/mock-llm/cli.ts",
60
+ "test:perf": "env -u PI_SUBAGENT_ROLE -u PI_SUBAGENT_ACCEPTANCE_ROLE -u PI_SUBAGENT_ALLOW_NESTED -u PI_SUBAGENT_CHILD -u PI_SUBAGENT_UTILITY_CHILD -u PI_SUBAGENT_MAX_DEPTH -u PI_SUBAGENT_PARENT_PATH -u PI_LEGION_DB -u PI_LEGION_NODE_ID node --experimental-strip-types --test test/perf/*.test.ts",
61
+ "test:wal-stress": "node --experimental-strip-types test/stress/wal-stress.ts",
60
62
  "test:mock-llm": "env -u PI_SUBAGENT_ROLE -u PI_SUBAGENT_ACCEPTANCE_ROLE -u PI_SUBAGENT_ALLOW_NESTED -u PI_SUBAGENT_CHILD -u PI_SUBAGENT_UTILITY_CHILD -u PI_SUBAGENT_MAX_DEPTH -u PI_SUBAGENT_PARENT_PATH -u PI_LEGION_DB -u PI_LEGION_NODE_ID node --experimental-strip-types --test test/unit/mock-llm*.test.ts test/integration/mock-llm*.test.ts test/integration/railway-mock-e2e.test.ts",
61
63
  "test:tui-visual": "docker build -q -f test/docker/Dockerfile.tui-sandbox -t ghcr.io/zzjcool/pi-legion/tui-sandbox:latest . >/dev/null && docker run --rm -v $(pwd):/plugin -w /plugin ghcr.io/zzjcool/pi-legion/tui-sandbox:latest bash test/docker/verify-tui-visual.sh"
62
64
  },
@@ -82,9 +82,8 @@ Keep the run/session records while the squad may need to resume. The
82
82
 
83
83
  A Git worktree is workspace separation, not a security sandbox. Read-only
84
84
  checks remain guardrails rather than a process boundary; never rely on them to
85
- protect against intentional writes. Pi Legion v2 supports `kind: pi`; for a
86
- role that requires a non-Pi CLI, use the Herdr-era 0.16.x release line rather
87
- than expecting v2 to launch it.
85
+ protect against intentional writes. Pi Legion runs `kind: pi` roles only; the
86
+ `kind` frontmatter key is still legal but `pi` is the only accepted value.
88
87
 
89
88
  ## Later control
90
89
 
@@ -30,6 +30,7 @@ import {
30
30
  stripQuotes,
31
31
  } from "./frontmatter.ts";
32
32
  import { getAgentDir } from "./paths.ts";
33
+ import { ignoredKindMessage, reportIgnoredOnce } from "../shared/ignored-kind.ts";
33
34
 
34
35
  export const BUILTIN_AGENT_NAMES = [
35
36
  "scout",
@@ -460,7 +461,7 @@ export function parseAgentDocument(
460
461
  inheritProjectContext: bool(fm.inheritProjectContext) ?? true,
461
462
  inheritSkills: bool(fm.inheritSkills) ?? false,
462
463
  ...(bool(fm.inheritExtensions) !== undefined ? { inheritExtensions: bool(fm.inheritExtensions) } : {}),
463
- kind: enumOr<AgentKind>(fm.kind, VALID_KINDS, "pi"),
464
+ kind: parseKind(fm.kind, name),
464
465
  systemPrompt: body,
465
466
  source,
466
467
  filePath,
@@ -496,17 +497,23 @@ function setIf<T extends object, K extends keyof T>(
496
497
  }
497
498
 
498
499
  /**
499
- * Pick a value only when it is a member of `allowed`.
500
- * An invalid enum degrades to the default rather than failing the whole file,
501
- * so a typo in one field cannot break discovery.
500
+ * Resolve the role's `kind`.
501
+ *
502
+ * v4 runs `pi` only. The key stays legal frontmatter (and `AgentConfig.kind`
503
+ * stays), but a value outside `AGENT_KINDS` is ignored: the role launches as
504
+ * `pi` and this is reported once, naming the role and the value it tried to
505
+ * set. Silently coercing would leave the user believing a non-pi CLI is in
506
+ * play, so an invalid/absent value is the only case that speaks up.
502
507
  */
503
- function enumOr<T extends string>(
504
- value: unknown,
505
- allowed: ReadonlySet<string>,
506
- fallback: T,
507
- ): T {
508
+ function parseKind(value: unknown, agentName: string): AgentKind {
508
509
  const raw = str(value);
509
- return raw && allowed.has(raw) ? (raw as T) : fallback;
510
+ if (raw === undefined || raw === "") return "pi";
511
+ if (VALID_KINDS.has(raw)) return raw as AgentKind;
512
+ reportIgnoredOnce(
513
+ `agent:${agentName}:${raw}`,
514
+ ignoredKindMessage({ origin: "agent", subject: `'${agentName}'`, value: raw }),
515
+ );
516
+ return "pi";
510
517
  }
511
518
 
512
519
  /** `systemPromptMode` is a closed set of two; anything else means `replace`. */
@@ -24,7 +24,6 @@ export const OVERRIDE_FIELDS = [
24
24
  "preset",
25
25
  "thinking",
26
26
  "output",
27
- "kind",
28
27
  "onBlocked",
29
28
  "systemPromptMode",
30
29
  "systemPrompt",
@@ -8,15 +8,15 @@
8
8
  *
9
9
  * Two invariants live here:
10
10
  *
11
- * 1. ATOMICITY — `kind` and `model` come from the same preset object.
12
- * `nativeModelFor("cursor", "cb/kimi-k3")` returns `undefined`, so a
13
- * mismatched pair would silently drop the model and start the child on
14
- * the CLI default. `assertKindModelCoherent` rejects that combination
15
- * at expansion time instead of letting the launch proceed.
11
+ * 1. ATOMICITY — `kind`, `model` and `thinking` come from the same preset
12
+ * object, so a preset can never ship half of one bundle and half another.
16
13
  *
17
14
  * 2. LOUD UNDEFINED — a referenced preset name with no matching entry is
18
15
  * an error that names the defined presets, never a silent fallback to
19
16
  * the dispatch model (mirrors `checkModelScope`'s out-of-scope error).
17
+ *
18
+ * v4 runs `kind: pi` only: a preset that names another kind is reported once
19
+ * and applied as `pi` (see `parsePreset`).
20
20
  */
21
21
 
22
22
  import {
@@ -27,7 +27,7 @@ import {
27
27
  type AgentKind,
28
28
  type PresetConfig,
29
29
  } from "../shared/types.ts";
30
- import { nativeModelFor } from "../runs/kind.ts";
30
+ import { ignoredKindMessage, reportIgnoredOnce } from "../shared/ignored-kind.ts";
31
31
 
32
32
  const VALID_KINDS: ReadonlySet<string> = new Set(AGENT_KINDS);
33
33
 
@@ -71,13 +71,17 @@ function parsePreset(
71
71
  const out: PresetConfig = {};
72
72
 
73
73
  if (input.kind !== undefined) {
74
+ // A non-`pi` kind is no longer a validation error (the field is still a
75
+ // legal key, it just only accepts one value). Report the ignored value once
76
+ // and apply the preset as `pi`, the single supported kind.
74
77
  if (typeof input.kind !== "string" || !VALID_KINDS.has(input.kind)) {
75
- throw new Error(
76
- `Subagent settings in '${filePath}' have invalid '${label}.kind'; ` +
77
- `expected one of: ${AGENT_KINDS.join(", ")}.`,
78
+ reportIgnoredOnce(
79
+ `preset:${name}`,
80
+ ignoredKindMessage({ origin: `preset '${name}'`, subject: `in ${filePath}`, value: input.kind }),
78
81
  );
82
+ } else {
83
+ out.kind = input.kind as AgentKind;
79
84
  }
80
- out.kind = input.kind as AgentKind;
81
85
  }
82
86
 
83
87
  if (input.model !== undefined) {
@@ -152,36 +156,6 @@ export function requirePreset(
152
156
  );
153
157
  }
154
158
 
155
- /**
156
- * Throw when `nativeModelFor(kind, model)` would silently drop the model.
157
- *
158
- * Reuses `nativeModelFor` rather than re-implementing the shape test, so the
159
- * guard can never disagree with the argv builder about what a kind accepts.
160
- *
161
- * `model` is the RESOLVED model — whichever precedence level actually won —
162
- * not the preset's own `model`, because a per-run tool `model`, `defaultModel`,
163
- * an override or the dispatch model can all beat or fill in for the preset.
164
- * The message therefore names the preset's KIND as the origin and reports
165
- * where the model came from, rather than implying the preset set it.
166
- */
167
- export function assertKindModelCoherent(
168
- kind: AgentKind,
169
- model: string | undefined,
170
- presetName: string,
171
- modelOrigin?: string,
172
- ): void {
173
- if (!model) return;
174
- if (nativeModelFor(kind, model) === undefined) {
175
- const origin = modelOrigin ? ` (from ${modelOrigin})` : "";
176
- throw new SubagentError(
177
- `Preset '${presetName}' selects kind '${kind}', but the resolved model '${model}'${origin} ` +
178
- `cannot be used with that kind and would be silently dropped at start. ` +
179
- `Fix the preset's kind or the model source.`,
180
- ErrorCodes.INVALID_PARAMS,
181
- );
182
- }
183
- }
184
-
185
159
  /**
186
160
  * Return a COPY of `agent` with kind/model/thinking from `preset`.
187
161
  *
@@ -11,7 +11,6 @@ import {
11
11
  AGENT_KINDS,
12
12
  DEFAULT_LEGION_SETTINGS,
13
13
  type AgentOverride,
14
- type HerdrSettings,
15
14
  type LegionSettings,
16
15
  type ModelScopeConfig,
17
16
  type OnBlockedPolicy,
@@ -28,6 +27,7 @@ import {
28
27
  readConventionContext,
29
28
  } from "./context.ts";
30
29
  import { OVERRIDE_FIELDS } from "./overrides.ts";
30
+ import { ignoredKindMessage, reportIgnoredOnce } from "../shared/ignored-kind.ts";
31
31
  import { parseModelScopeConfig } from "./model-scope.ts";
32
32
  import { parsePresets } from "./presets.ts";
33
33
 
@@ -88,8 +88,6 @@ function nonNegativeInt(
88
88
  }
89
89
 
90
90
  const VALID_JOIN_MODES: ReadonlySet<string> = new Set(["each", "smart"]);
91
- const LEGACY_PLACEMENTS = ["split-down", "split-right", "new-tab"] as const;
92
- const warnedHerdrSettings = new Set<string>();
93
91
 
94
92
  const VALID_ON_BLOCKED_POLICIES: ReadonlySet<string> = new Set([
95
93
  "forward",
@@ -116,73 +114,6 @@ function readJoinFields(
116
114
  if (flushMs !== undefined) out.joinFlushMs = flushMs;
117
115
  }
118
116
 
119
- function parseHerdrSettings(
120
- value: unknown,
121
- filePath: string,
122
- ): HerdrSettings | undefined {
123
- if (value === undefined) return undefined;
124
- if (!value || typeof value !== "object" || Array.isArray(value)) {
125
- throw new Error(
126
- `Subagent settings in '${filePath}' have invalid 'herdr'; expected an object.`,
127
- );
128
- }
129
- const input = value as Record<string, unknown>;
130
- const out: HerdrSettings = {};
131
- if (!warnedHerdrSettings.has(filePath)) {
132
- warnedHerdrSettings.add(filePath);
133
- console.warn(
134
- `[pi-legion] Settings 'subagents.herdr' in ${filePath} are deprecated and ignored by the RPC supervisor; remove them when migrating from v0.16.x.`,
135
- );
136
- }
137
-
138
- const placement = input.defaultPlacement;
139
- if (placement !== undefined) {
140
- if (typeof placement !== "string" || !LEGACY_PLACEMENTS.includes(placement as (typeof LEGACY_PLACEMENTS)[number])) {
141
- throw new Error(
142
- `Subagent settings in '${filePath}' have invalid 'herdr.defaultPlacement'; ` +
143
- `expected one of: ${[...LEGACY_PLACEMENTS].join(", ")}.`,
144
- );
145
- }
146
- out.defaultPlacement = placement as NonNullable<HerdrSettings["defaultPlacement"]>;
147
- }
148
-
149
- const maxAgents = positiveInt(
150
- input.maxConcurrentAgents,
151
- "herdr.maxConcurrentAgents",
152
- filePath,
153
- );
154
- if (maxAgents !== undefined) out.maxConcurrentAgents = maxAgents;
155
-
156
- const retries = nonNegativeInt(
157
- input.startRetries,
158
- "herdr.startRetries",
159
- filePath,
160
- );
161
- if (retries !== undefined) out.startRetries = retries;
162
-
163
- const backoff = nonNegativeInt(
164
- input.startRetryBackoffMs,
165
- "herdr.startRetryBackoffMs",
166
- filePath,
167
- );
168
- if (backoff !== undefined) out.startRetryBackoffMs = backoff;
169
-
170
- const days = positiveInt(
171
- input.sessionRetentionDays,
172
- "herdr.sessionRetentionDays",
173
- filePath,
174
- );
175
- if (days !== undefined) out.sessionRetentionDays = days;
176
-
177
- const bytes = positiveInt(
178
- input.sessionRetentionMaxBytesPerRun,
179
- "herdr.sessionRetentionMaxBytesPerRun",
180
- filePath,
181
- );
182
- if (bytes !== undefined) out.sessionRetentionMaxBytesPerRun = bytes;
183
-
184
- return out;
185
- }
186
117
 
187
118
  const LEGION_PHASES = [
188
119
  "planning",
@@ -477,8 +408,6 @@ export function parseSubagentSettings(
477
408
  const modelScope = parseModelScopeConfig(input.modelScope, { filePath });
478
409
  if (modelScope) out.modelScope = modelScope;
479
410
 
480
- const herdr = parseHerdrSettings(input.herdr, filePath);
481
- if (herdr) out.herdr = herdr;
482
411
  const legion = parseLegionSettings(input.legion, filePath);
483
412
  if (legion !== undefined) out.legion = legion;
484
413
 
@@ -604,6 +533,21 @@ function parseInProcessLimits(
604
533
  filePath,
605
534
  );
606
535
  if (waitTimeoutMs !== undefined) out.waitTimeoutMs = waitTimeoutMs;
536
+ // D10: launch-admission-queue bounds. `positiveInt` on purpose — 0 is
537
+ // invalid, not "queue disabled" (that would silently swallow every queue).
538
+ // Defaults live in `src/supervisor/admission-queue.ts` (single source).
539
+ const maxQueuedLaunches = positiveInt(
540
+ value.maxQueuedLaunches,
541
+ "inprocess.maxQueuedLaunches",
542
+ filePath,
543
+ );
544
+ if (maxQueuedLaunches !== undefined) out.maxQueuedLaunches = maxQueuedLaunches;
545
+ const launchQueueTimeoutMs = positiveInt(
546
+ value.launchQueueTimeoutMs,
547
+ "inprocess.launchQueueTimeoutMs",
548
+ filePath,
549
+ );
550
+ if (launchQueueTimeoutMs !== undefined) out.launchQueueTimeoutMs = launchQueueTimeoutMs;
607
551
  // Unknown keys are ignored (same as other settings blobs).
608
552
  return out;
609
553
  }
@@ -639,6 +583,15 @@ function parseAgentOverride(
639
583
  // though older settings files may still carry it in an agent override.
640
584
  const normalized: Record<string, unknown> = { ...value };
641
585
  delete normalized.placement;
586
+ // A non-`pi` kind is reported once and ignored rather than rejected: the key
587
+ // is still legal, it only has one accepted value in v4.
588
+ if (normalized.kind !== undefined && !isValidOverrideField("kind", normalized.kind)) {
589
+ reportIgnoredOnce(
590
+ `override:${field}`,
591
+ ignoredKindMessage({ origin: `settings override '${field}'`, subject: `in ${filePath}`, value: normalized.kind }),
592
+ );
593
+ delete normalized.kind;
594
+ }
642
595
  for (const [key, member] of Object.entries(normalized)) {
643
596
  const expected = overrideFieldExpected(key);
644
597
  if (
@@ -814,7 +767,7 @@ export function loadSubagentSettings(
814
767
 
815
768
  /**
816
769
  * Merge project settings over user settings.
817
- * `herdr`, `legion`, `agentOverrides`, `teams` and `presets` shallow-merge;
770
+ * `legion`, `agentOverrides`, `teams` and `presets` shallow-merge;
818
771
  * `legion.phaseTimeouts` also merges per phase. `modelScope` is replaced
819
772
  * wholesale. A project team with the same name replaces the user's complete
820
773
  * team definition.
@@ -863,10 +816,6 @@ export function resolveSubagentSettings(
863
816
  }
864
817
  if (project.team !== undefined) out.team = project.team;
865
818
 
866
- if (project.herdr) {
867
- out.herdr = { ...(user.herdr ?? {}), ...project.herdr };
868
- }
869
-
870
819
  if (project.legion !== undefined || user.legion !== undefined) {
871
820
  out.legion = mergeLegionSettings(user.legion, project.legion);
872
821
  }
@@ -9,18 +9,14 @@
9
9
  * 1. A referenced preset's kind/model/thinking reach the child, and its
10
10
  * model beats `agentOverrides` (which `loadCatalog` folds into
11
11
  * `agent.model` before anything here runs).
12
- * 2. `kind` and the FINAL model are coherent, or the launch is refused
13
- * loudly instead of dropping the model at start (`nativeModelFor`).
14
12
  *
15
- * Invariant 2 is checked on the resolved model, NOT on the preset's own
16
- * `model`: the launched model can come from a level above or below the preset
17
- * (a per-run tool `model`, `defaultModel`, the dispatch model, an override),
18
- * so validating the preset's own pair would let an incoherent pair ship.
13
+ * v4 runs `kind: pi` only, so there is no second invariant to police here:
14
+ * the resolved model always belongs to the one supported kind.
19
15
  */
20
16
 
21
17
  import type { AgentConfig, ModelOrigin, PresetConfig } from "../shared/types.ts";
22
18
  import { providerOf, resolveModel, type ResolvedModel } from "./model-resolution.ts";
23
- import { assertKindModelCoherent, expandPreset } from "./presets.ts";
19
+ import { expandPreset } from "./presets.ts";
24
20
 
25
21
  export interface StepModelInput {
26
22
  /** The catalog agent, with `agentOverrides` already folded in. */
@@ -59,9 +55,8 @@ export interface StepModelResult {
59
55
  /**
60
56
  * Resolve the model (and the effective agent) a step will launch with.
61
57
  *
62
- * Throws when a referenced preset is undefined, or when the resolved
63
- * kind/model pair would be silently dropped at start. Callers are expected to
64
- * turn those into a refusal line rather than letting them escape.
58
+ * Throws when a referenced preset is undefined. Callers are expected to turn
59
+ * that into a refusal line rather than letting it escape.
65
60
  */
66
61
  export function resolveStepModel(input: StepModelInput): StepModelResult {
67
62
  const override = input.step.model ?? input.params.model;
@@ -88,18 +83,8 @@ export function resolveStepModel(input: StepModelInput): StepModelResult {
88
83
  settings: input.settings as Parameters<typeof resolveModel>[0]["settings"],
89
84
  });
90
85
 
91
- // Only a preset can make the kind disagree with the launched model in a way
92
- // this module is responsible for. An agent that merely sets `kind` keeps its
93
- // pre-existing behaviour (that is an older, separate concern).
94
- if (expanded) {
95
- const origin = classifyModelOrigin(resolved, override);
96
- assertKindModelCoherent(
97
- effective.kind,
98
- resolved.model,
99
- expanded.presetName,
100
- origin.label,
101
- );
102
- }
86
+ // Only a preset can change the kind, and v4 accepts `pi` alone, so nothing
87
+ // here can leave the kind and the launched model incoherent.
103
88
 
104
89
  return {
105
90
  resolved,
package/src/api.ts CHANGED
@@ -16,19 +16,6 @@
16
16
  * ```
17
17
  */
18
18
 
19
- // ── deprecated herdr transport ──────────────────────────────────────────────
20
- /** @deprecated Use `RpcSupervisor` for the v2 execution path. */
21
- export {
22
- createHerdrClient,
23
- parseHerdrResponse,
24
- mapHerdrErrorCode,
25
- readPaneDiagnostic,
26
- SubagentError,
27
- } from "./backends/legacy/client.ts";
28
- /** @deprecated Only for pi-legion v0.16.x Herdr workflows. */
29
- export { createCommandRunner, resolveHerdrBin } from "./backends/legacy/runner.ts";
30
- export type { RunnerOptions } from "./backends/legacy/runner.ts";
31
-
32
19
  // ── agent definitions ───────────────────────────────────────────────────────
33
20
  export {
34
21
  BUILTIN_AGENT_NAMES,
@@ -66,7 +53,6 @@ export type {
66
53
  } from "./agents/model-resolution.ts";
67
54
  export {
68
55
  applyPreset,
69
- assertKindModelCoherent,
70
56
  expandPreset,
71
57
  parsePresets,
72
58
  requirePreset,
@@ -191,13 +177,6 @@ export {
191
177
  THINKING_LEVELS,
192
178
  } from "./runs/args.ts";
193
179
  export type { BuildArgsInput, BuildArgsResult } from "./runs/args.ts";
194
- export {
195
- cursorModel,
196
- isPiShapedModel,
197
- nativeModelFor,
198
- planKindStart,
199
- } from "./runs/kind.ts";
200
- export type { KindStartPlan } from "./runs/kind.ts";
201
180
  export {
202
181
  createChildWorktree,
203
182
  isGitRepo,
@@ -292,7 +271,7 @@ export {
292
271
  export type { LiveProgress } from "./shared/progress.ts";
293
272
 
294
273
  // ── shared vocabulary ───────────────────────────────────────────────────────
295
- export { AGENT_KINDS } from "./shared/types.ts";
274
+ export { AGENT_KINDS, SubagentError } from "./shared/types.ts";
296
275
  export type {
297
276
  AcceptanceConfig,
298
277
  AcceptanceCriterion,
@@ -326,16 +305,3 @@ export type {
326
305
  TurnBudgetConfig,
327
306
  Usage,
328
307
  } from "./shared/types.ts";
329
- /** @deprecated v0.16.x pane backend compatibility types. */
330
- export type {
331
- AgentInfo,
332
- AgentStartResult,
333
- CommandRunner,
334
- HerdrClient,
335
- HerdrError,
336
- HerdrResult,
337
- PaneInfo,
338
- ProcessInfo,
339
- ReadSource,
340
- TabInfo,
341
- } from "./backends/legacy/types.ts";
@@ -2,11 +2,20 @@ import type { DatabaseSync } from "../legion/db.ts";
2
2
  import {
3
3
  LEGION_EVENT_TYPES as STORED_LEGION_EVENT_TYPES,
4
4
  getDataVersion,
5
- readEventsAfter,
5
+ readEventsAfterTolerant,
6
6
  } from "../legion/events.ts";
7
7
  import type { LegionEventType as StoredLegionEventType } from "../legion/events.ts";
8
8
 
9
- /** Process-only activity. These events are never written to the closed DB enum. */
9
+ /**
10
+ * Process-only activity. These events are never written to the closed DB enum.
11
+ *
12
+ * Launch-admission-queue (issue #266, contract D12) belongs here and ONLY here:
13
+ * queue residency is a fact about *this* process (the queue lives in memory and
14
+ * dies with it), so persisting it would be meaningless. Adding a type to
15
+ * `src/legion/events.ts` instead would freeze the DB event cursor (#233); the
16
+ * mirrored pair of assertions in `test/unit/teams.test.ts` (A13) guards both
17
+ * directions of that boundary.
18
+ */
10
19
  export const LEGION_PROCESS_EVENT_TYPES = [
11
20
  "node_launched",
12
21
  "node_steered",
@@ -17,6 +26,8 @@ export const LEGION_PROCESS_EVENT_TYPES = [
17
26
  "tool_call",
18
27
  "agent_settled",
19
28
  "turn_start",
29
+ "launch_queued",
30
+ "launch_admitted",
20
31
  ] as const;
21
32
  export type LegionProcessEventType = (typeof LEGION_PROCESS_EVENT_TYPES)[number];
22
33
 
@@ -46,14 +57,41 @@ export type LegionEventHandler = (event: LegionEvent) => void;
46
57
  export interface LegionEventBus {
47
58
  on(type: LegionEventType, handler: LegionEventHandler): () => void;
48
59
  emit(event: LegionEvent): void;
60
+ /**
61
+ * Report an error raised OUTSIDE a subscriber's synchronous dispatch window
62
+ * (a deferred repaint) through the same `onHandlerError` channel a throwing
63
+ * handler uses. `createLegionEventBus` isolates a handler's throw so one bad
64
+ * consumer cannot break delivery; a subscriber that defers work past its own
65
+ * dispatch window would otherwise escape that isolation entirely (#238).
66
+ *
67
+ * There is deliberately NO `event`/`handler` argument: the throw happened after
68
+ * dispatch, so there is no real event to attribute it to, and fabricating one
69
+ * (a synthetic `ui_request` on an empty nodeId) would make any consumer that
70
+ * groups by `event.nodeId` record a phantom event on a node that never exists.
71
+ * Consumers that need correlation should use `context`.
72
+ *
73
+ * Optional so any structural stand-in for a bus keeps working; a caller with no
74
+ * channel must contain the error itself rather than rethrow it onto the
75
+ * microtask queue (which is a host-killing crash, the very bug this prevents).
76
+ */
77
+ reportError?(error: unknown, context: "deferred-handler"): void;
49
78
  }
50
79
 
51
80
  export interface LegionEventBusOptions {
52
81
  /** Optional observer for isolated subscriber failures. Failures here are isolated too. */
53
82
  onHandlerError?: (
54
83
  error: unknown,
55
- event: LegionEvent,
56
- handler: LegionEventHandler,
84
+ /**
85
+ * The event whose handler threw, or `null` for a failure reported outside a
86
+ * dispatch window (`reportError`, e.g. a deferred repaint). `null` means "no
87
+ * event", never "an event with no node": do not group it by `nodeId`.
88
+ */
89
+ event: LegionEvent | null,
90
+ /**
91
+ * The failing handler, or a short origin tag for a deferred failure
92
+ * (`"deferred-handler"`) where no single handler is responsible.
93
+ */
94
+ handler: LegionEventHandler | string,
57
95
  ) => void;
58
96
  }
59
97
 
@@ -110,6 +148,18 @@ export function createLegionEventBus(
110
148
  }
111
149
  }
112
150
  },
151
+ reportError(error, context) {
152
+ // No `event`/`handler` to attribute this to: the throw happened after the
153
+ // dispatch window closed. Pass only what is real (the error and the
154
+ // origin) — a synthetic event would be a fabricated fact. An unconfigured
155
+ // `onHandlerError` stays silent, which is what `emit()` already does for a
156
+ // throwing handler; `index.ts` wires one so the containment is not silent.
157
+ try {
158
+ options.onHandlerError?.(error, null, context);
159
+ } catch {
160
+ // Error reporting must never itself break delivery.
161
+ }
162
+ },
113
163
  };
114
164
  }
115
165
 
@@ -123,7 +173,15 @@ export interface EventSourceClock {
123
173
  export interface DbEventSourceOptions {
124
174
  /** Contract §10.2 recommends 200–500ms; default is 250ms. */
125
175
  pollMs?: number;
126
- /** Resume after this persisted event id. Defaults to the beginning of the log. */
176
+ /**
177
+ * Resume after this persisted event id. Defaults to the beginning of the log.
178
+ *
179
+ * The default is deliberately "replay everything": a caller that attaches to
180
+ * a ledger it did not just write should seed this at the current tail instead
181
+ * (`maxEventId(db)` from `src/legion/events.ts`) — that is what the production
182
+ * attach path in `index.ts` does (#238, `createLegionRuntime`). A caller that
183
+ * really wants the full replay passes `cursor: 0` explicitly.
184
+ */
127
185
  cursor?: number;
128
186
  /** Injectable interval scheduler for deterministic tests and embedding. */
129
187
  clock?: EventSourceClock;
@@ -193,13 +251,27 @@ export function createDbEventSource(
193
251
  // Drain all batches for this dirty version so the cursor cannot strand
194
252
  // events when a busy producer commits more than one batch at once.
195
253
  for (;;) {
196
- const batch = readEventsAfter(db, cursor, { limit: EVENT_BATCH_SIZE });
254
+ let skipped = 0;
255
+ const batch = readEventsAfterTolerant(db, cursor, {
256
+ limit: EVENT_BATCH_SIZE,
257
+ onInvalidRow: (error, rowId) => {
258
+ skipped += 1;
259
+ reportError(error);
260
+ // Advance past the undecodable row so one bad row cannot stall the stream.
261
+ if (rowId > cursor) cursor = rowId;
262
+ },
263
+ });
197
264
  for (const event of batch) {
198
265
  currentBus.emit(event);
199
- cursor = event.id;
266
+ // Rows are ascending, but onInvalidRow may already have moved the cursor
267
+ // past a poison row in this same batch. Never let a decoded row rewind it,
268
+ // or the skipped row is re-read and re-reported on the next poll.
269
+ if (event.id > cursor) cursor = event.id;
200
270
  if (!running || bus !== currentBus) return;
201
271
  }
202
- if (batch.length < EVENT_BATCH_SIZE) break;
272
+ // Skips count toward fullness: a batch of only poison rows still read
273
+ // a full page, so it must not be mistaken for the tail of the log.
274
+ if (batch.length + skipped < EVENT_BATCH_SIZE) break;
203
275
  }
204
276
  lastDataVersion = dataVersion;
205
277
  } catch (error) {