pi-legion 5.0.1 → 5.2.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 (79) hide show
  1. package/CHANGELOG.md +858 -0
  2. package/CONTRIBUTING.md +33 -2
  3. package/README.md +10 -5
  4. package/agents/centurion.md +32 -7
  5. package/agents/scout.md +11 -0
  6. package/docs/contracts/abort-soft-interrupt-v1.md +162 -0
  7. package/docs/contracts/centurion-declared-phases-v1.md +104 -0
  8. package/docs/contracts/declarative-phase-machine-v5.md +43 -0
  9. package/docs/contracts/extension-inheritance-wiring-v1.md +88 -0
  10. package/docs/contracts/finished-cache-eviction-v1.md +169 -0
  11. package/docs/contracts/galaxy-scratch-v1.md +78 -0
  12. package/docs/contracts/legion-chart-frame-buckets-v1.md +201 -0
  13. package/docs/contracts/legion-main-screen-mount-v1.md +161 -0
  14. package/docs/contracts/legion-page-floor-v1.md +129 -0
  15. package/docs/contracts/legion-session-continuity-v1.md +118 -0
  16. package/docs/contracts/mailbox-delivery-v1.md +162 -0
  17. package/docs/contracts/notice-claim-v1.md +206 -0
  18. package/docs/contracts/partial-failure-exit-v1.md +235 -0
  19. package/docs/contracts/proc-sweep-v1.md +107 -0
  20. package/docs/contracts/provider-empty-generation-retry-v1.md +161 -0
  21. package/docs/contracts/stall-visibility-v1.md +218 -0
  22. package/docs/contracts/subagent-renewal-v1.md +5 -3
  23. package/docs/contracts/vexillatio-nonblocking-launch-v1.md +1 -0
  24. package/docs/contracts/viz-frame-alloc-v1.md +123 -0
  25. package/docs/contracts/wait-mail-preempt-v1.md +117 -0
  26. package/docs/contracts/wait-not-stop.md +1 -1
  27. package/docs/design.md +4 -1
  28. package/docs/inprocess-supervisor.md +9 -3
  29. package/docs/legion-v2-contract.md +122 -20
  30. package/docs/vexillatio-contract.md +5 -1
  31. package/index.ts +1903 -130
  32. package/package.json +5 -5
  33. package/skills/pi-legion/SKILL.md +2 -1
  34. package/src/agents/agents.ts +26 -1
  35. package/src/agents/settings.ts +67 -2
  36. package/src/api.ts +17 -0
  37. package/src/extension/child-guard.ts +2 -1
  38. package/src/extension/join.ts +54 -0
  39. package/src/extension/legion-commands.ts +15 -4
  40. package/src/extension/legion-doctor.ts +48 -6
  41. package/src/extension/legion-status.ts +44 -2
  42. package/src/extension/legion-tools.ts +1044 -109
  43. package/src/extension/notify.ts +31 -2
  44. package/src/extension/playbook.ts +6 -3
  45. package/src/extension/runtime.ts +882 -81
  46. package/src/extension/spawn-hints.ts +52 -12
  47. package/src/extension/summary.ts +7 -15
  48. package/src/extension/timing.ts +4 -15
  49. package/src/legion/db.ts +23 -1
  50. package/src/legion/events.ts +7 -0
  51. package/src/legion/interrupt.ts +98 -0
  52. package/src/legion/mail-release.ts +266 -0
  53. package/src/legion/mail.ts +456 -29
  54. package/src/legion/nodes.ts +31 -0
  55. package/src/legion/phase-conditions.ts +7 -1
  56. package/src/legion/phase-machine.ts +160 -8
  57. package/src/legion/state-machine.ts +34 -2
  58. package/src/runs/acceptance.ts +105 -6
  59. package/src/runs/args.ts +16 -0
  60. package/src/runs/child-scope.ts +323 -0
  61. package/src/runs/extension-inheritance.ts +145 -19
  62. package/src/runs/orchestrator.ts +613 -55
  63. package/src/runs/store.ts +149 -5
  64. package/src/shared/proc-sweep.ts +317 -0
  65. package/src/shared/progress.ts +11 -2
  66. package/src/shared/session-map.ts +29 -0
  67. package/src/shared/session.ts +308 -6
  68. package/src/shared/types.ts +34 -0
  69. package/src/supervisor/inprocess-supervisor.ts +158 -12
  70. package/src/supervisor/race-timeout.ts +55 -0
  71. package/src/supervisor/routing-supervisor.ts +55 -4
  72. package/src/supervisor/rpc-supervisor.ts +215 -29
  73. package/src/supervisor/types.ts +439 -2
  74. package/src/tui/status.ts +2 -1
  75. package/src/tui/viz/canvas.ts +358 -36
  76. package/src/tui/viz/palette.ts +18 -2
  77. package/src/tui/viz/panels.ts +507 -104
  78. package/src/tui/viz/rate.ts +120 -15
  79. package/src/tui/viz/view.ts +72 -4
package/CHANGELOG.md CHANGED
@@ -11,6 +11,864 @@ summary so you can see **2.x vs 0.16** without leaving the tree.
11
11
 
12
12
  ## [Unreleased]
13
13
 
14
+ ## [5.2.0] — 2026-11-04
15
+
16
+ 一次以「回复说真话」为中心的发布。批量派发的每个子节点可以带自己的 handle;`stop` 与超时的回复不再把
17
+ 「已经跑完」「还在跑」说成相反的东西;`collect` 与 `stop` 撞车时不再丢最后一轮产物;文档里「`timeoutMs`
18
+ 会终止子节点」的说法被容器实测推翻并改正。
19
+
20
+ ### Added
21
+
22
+ - **`tasks[]` 的每个条目现在可以带自己的 `name`,而且这个 handle 一定生效。** 以前条目里的 `name` 被
23
+ 静默丢弃——schema 里没有这个字段、`launchStep` 也从不读它,于是调用方明明写了名字,批量出来的子节点仍被
24
+ 自动编号成 `scout-N`/`worker-N`,没有任何提示;现在条目名优先、其次顶层 `name=`、都没有才自动编号。名字不合
25
+ 语法、同一批内重名(含大小写/空格差异)、或单条目同时给了两个不同的名字,都在**任何子节点启动之前**整批拒绝,
26
+ 不会留下「一半按调用方的名字、一半按自动名」的半启动状态。`vexillatio` 自己为每一步分配唯一 handle,因此对
27
+ 条目里的 `name` 直接报错拒绝,而不是静默忽略。
28
+
29
+ ### Fixed
30
+
31
+ - **`stop` 的回复现在带状态,不再把「已经跑完」说成「被停掉」。** 名字在子节点 retire 之后才到达时,回复是
32
+ `No non-retired children to stop. worker-2 is already retired (execution: success, retired at …)`;命中
33
+ settle→retire 那一小段窗口时,回复 `Retired 1 already-settled child: x (execution: success)` 而不是
34
+ `Stopped 1 child`——后者等于宣称一次从未发生的打断。retire 行为本身不变,只有措辞。
35
+
36
+ - **超时不再被说成会终止子节点,`stop` 竞态也不再丢最后一轮产物。** 角色 `timeoutMs` 实际只是父侧等待预算:
37
+ 容器实测 `worker.timeoutMs=10s` 的 child 存活 56s 并正常 `success`,且 pi 1.4 的 CLI / settings 都没有 turn 级超时;
38
+ 文档与 renewal 契约据此改正,`collect deadline (cumulative since launch)` 改为 `(per collect attempt)`(原文案
39
+ 本来就是错的)。`finishCollect` 在「child 已被 retire」时仍写 `<name>.output.md`,不再因 `stop` 撞上 collect
40
+ 而丢掉那一轮文本。失败通知携带 `stopReason` / `errorMessage` / `errorKind`,当合成 reason 表达不了 provider
41
+ 错误时多打一行 `error:`;`wait` 超时回执的 `hint` 改为「只是停止等待,child 还在跑」,不再把 `stop` 摆在建议位。
42
+
43
+ ## [5.1.0] — 2026-11-02
44
+
45
+ 一次以**可恢复、可声明的子树**为中心的发布。三个新能力:`centurion` 的状态机改为由 root 派发时用 `phases:` 声明(不声明就是普通 agent);同一个 session 重开能接回上次的树,退出等于暂停、恢复必须显式;长等待中的 `wait`/`collect` 会在收到子节点邮件时立刻返回。`/legion` 一侧:整帧分配降到 2.0MB 以内(120×40 −59%、200×60 −67%),overlay 改为主屏挂载,状态行能说清子节点正在做什么,且不再每写一格分配一个 `Cell`。另有三十余项稳定性修复:失败队不再静默消失、内联相位机器进树、headless 账本不再漏记、`ctx is stale` 与宿主 `turn_end` 的报错消失。
46
+
47
+ ### Added
48
+
49
+ - **`legion_phase` gained the two arguments a centurion actually needed: `supersedes`
50
+ and `reason`.** `implementing` had no legal exit for the most common way a squad settles — one child
51
+ failed, the rest settled. `implementing → reviewing` demands `children_none_failed`, which only
52
+ tolerates failures listed in `handledIds`; that set had exactly one producer, and its hard
53
+ precondition was `reworkCount > 0`, which increments only on the two `rework: true` edges —
54
+ `implementing` has none. `implementing → failed` demands `children_all_failed`, and
55
+ `to: "failed"` was refused on the tool surface before the state machine ever ran. Meanwhile
56
+ `terminalReason: "parent_decision"` was already unconditionally accepted by the state machine
57
+ with zero producers in `src/`. Both gaps are now closed without touching the transition graph: a
58
+ centurion may declare `to: "failed"` **with a reason** (a missing reason is refused, and `done`/`aborted`
59
+ stay off-limits because they belong to the parent or supervisor), and may pass
60
+ `supersedes: [<failed child>]` to name which failures a settled sibling has taken over — an
61
+ extra source unioned into the existing `reworkCount > 0` / `updatedAt < phaseSince` derivation,
62
+ never a replacement for it, with a `node_handled` audit event per handled id. A declared failure
63
+ still has to be a declared exit: the tool only hands the state machine that terminal reason when
64
+ the team's effective (normalized) machine offers a `failed` edge out of the current phase that
65
+ this node may take, so a card that reserves `failed` to another actor — or declares the phase but
66
+ omits the edge — keeps its veto, and the persisted reason is capped like `note`/`evidence`. A
67
+ declared failure is a terminal verdict, so it now mails the parent like the other two failure
68
+ paths do instead of leaving it to poll the ledger. The
69
+ refusal text for the deadlock now names both of those calls instead of two actions that were
70
+ unreachable in the current code. Contract: `docs/contracts/partial-failure-exit-v1.md` (#309).
71
+
72
+ - **`status` now says what a live child is doing and for how long — `working · bash 00:07:31` —
73
+ and marks a single tool that has run unchanged for five minutes.** "The process is alive but has
74
+ stopped progressing" was invisible everywhere: the strongest `liveness()` rule calls any child
75
+ holding a non-terminal handle `live`, the heartbeat refreshes for exactly those children, and the
76
+ orphan scan exempts a node whose first root child is still alive, so a child stuck inside one
77
+ tool call looked identical to one doing useful work and the parent learned otherwise only when
78
+ its 900s `collect` deadline fired. Both backends now record the one tool call a child is inside —
79
+ tool name plus the host clock reading when its own `tool_execution_start` arrived — onto the child
80
+ record, folded from the session event stream each backend already consumes (no new IPC, no
81
+ polling, no new state machine). `subagent({ action: "status" })`, the `all: true` overview and
82
+ `/legion status <name>` lead each block with that fact and, at or past
83
+ `STALL_NOTICE_MS = 300_000`, append a marker naming how many whole minutes the call has been
84
+ stuck and which lever can move it — `steer` cannot reach a child that is inside a tool call
85
+ (#313), `abort` can (#314).
86
+ **This is display only**: `liveness()` is untouched, no heartbeat or orphan-scan criterion moved,
87
+ and nothing is ever auto-killed, aborted or retired — the marker names the levers, the parent
88
+ still pulls them. A child with no in-flight tool renders exactly the pre-#315 block, byte for
89
+ byte. The one behaviour change is that the read-only `scout` role now declares
90
+ `toolTimeoutMs: 600000`, because `toolTimeoutMs` had no default and the SDK bash tool has no
91
+ default timeout, so a single hung probe could pin a recon child for its whole turn budget and
92
+ report nothing; `worker` and `centurion` deliberately keep no default, since killing a
93
+ slow-but-progressing build or integration run costs more than the hang it would prevent. Contract and the frozen boundary:
94
+ `docs/contracts/stall-visibility-v1.md` (#315).
95
+
96
+ - **`subagent({ action: "abort" })` stops a child's current turn without killing it.** The
97
+ capability was already sitting in the supervisor (`LegionSupervisor.abort`, `RpcSupervisor`,
98
+ `InProcessSupervisor`, `Orchestrator.abort`) with zero call sites and no action on the tool
99
+ surface, so the only ways out of a stuck child were `stop` (tear the process down and throw the
100
+ context away) and `steer` (a queue, so it cannot reach a child that is inside a tool call). The
101
+ action now clears the child's queued steering/follow-up messages first — `clearQueued`,
102
+ default `true`, because an abort that lands first lets the child immediately pick up the oldest
103
+ queued instruction, the opposite of "stop that" — and then asks the child to stop the current
104
+ turn. Context, transcript and session file survive, so the child can be continued afterwards.
105
+ The call is bounded: one 5s limit covers the interrupt **and** the queue clear that has to land
106
+ before it, so the worst case is about twice that bound — and the tool surface takes no bound of
107
+ its own (`timeoutMs` stays a `wait`-only parameter; a clear that cannot answer counts as zero
108
+ while the interrupt is still sent). A call that hits the bound answers `timeout` instead of
109
+ hanging, and it never kills anything: `stop` (`force: true` for the hard variant) remains the
110
+ path that takes the process down. Three honest receipts — `interrupted` (with the count of
111
+ queued messages it dropped), `no-turn` (nothing was running; the queue is left alone) and
112
+ `timeout` (the request went out, the child had not settled). Contract and the frozen three-state
113
+ table: `docs/contracts/abort-soft-interrupt-v1.md` (#314).
114
+
115
+ ### Changed
116
+
117
+ - **交付物是分支,MR 只是有 remote 时的便利。** 以前角色卡、注入给子节点的任务附录、验证后的提示语都写「开 MR/PR 并把 URL 写进 verdict」,
118
+ 在**零 remote** 的仓库里等于要求一个不可能的东西(实测一次真实会话:centurion 只能交一条待推分支,如实报 `no remote`)。
119
+ 现在统一的措辞是:**分支名 + HEAD sha 就是交付**;仓库有 remote 且 `gh`/`glab` 可用时再附 MR/PR 链接,缺失不算失败,**任何情况下不许编造 URL**。
120
+ 逐级合并权不变(仍由父节点合并,认分支/提交而非平台对象)。见 `docs/legion-v2-contract.md` REV-29。
121
+
122
+ - **长等待中收到子 agent 的邮件会立刻返回,而不是等这一轮结束 (#384).** `legion_mail → root` 走宿主的
123
+ followUp,而宿主只在一轮「本来要停」时才取走它——于是根会话只要在 `subagent({action:"wait"})` 里,邮件就跟着
124
+ 一起排队(实测一封冻结契约的邮件等了 17.3 分钟)。现在 `wait`/`collect` 在调用者是 legion 节点时每 250ms 看
125
+ 一眼自己的收件箱:出现 `PENDING` 邮件就立即返回,正文就是那封邮件(`formatMail` 原文,多封按 id 升序、最多
126
+ 100 封)加一行状态说明(`wait`:`Still running: <name>, …`;`collect`:提示处理完再 collect 一次),
127
+ `details.state = "mail"`。返回是**普通工具结果**而不是错误/中断,模型读完邮件继续干活。
128
+ 邮件只被取走一次:**发送前先在账本上认领**(§3.1 的 `delivered_at` 租约,postman 与抢占方用同一个条件更新,
129
+ 输的一方跳过自己的发送),以及**阻塞调用期间该节点的 postman 停手**(§1.1,邮件留在 PENDING 供抢占);
130
+ 账本仍是 postman 同一套状态(`PENDING → ACCEPTED`,不新增事件、不改 schema)。子节点状态与进程完全不受影响(只返回,不 retire/abort);Esc/父轮结束仍是原来的
131
+ `interrupted (parent turn ended; children still running)`,谁先到谁赢。没有邮件时 `wait`/`collect` 的结果与今天
132
+ 逐字节一致。契约:`docs/contracts/wait-mail-preempt-v1.md`。
133
+
134
+ - **`/legion` 两个画布面板的帧内容器收成了一个 `GalaxyScratch` 单例 (#369).** 对维护者:`panels.ts`
135
+ 的 26 个模块级可变容器(braille 网格及其尺寸键、19 个 `galaxy*`、4 个 chart 容器)不再散在文件里、
136
+ 也不再各自在函数中途清零——闸门之后的第一条语句就是唯一入口 `begin()`,它一次性清空全部带长度/
137
+ 计数器的容器(`clear()`/`length = 0`/计数归零,不分配、身份跨帧保持);七个按计数器寻址的索引数组
138
+ 只归零计数器,截断它们反而会分配(契约 §1.2),于是「这一帧此刻什么是干净的」只有一个答案。同时两个面板都加了不可重入闸门(嵌套渲染抛 `viz: <fn> is not reentrant`,以前这条
139
+ 约束只写在注释里)。用户无感知:可见输出逐字节不变,整帧分配不回退(bench 10 全绿)。
140
+ 契约:`docs/contracts/galaxy-scratch-v1.md`。
141
+
142
+ - **退出/恢复会话时不再打印 `Extension error (/plugin/index.ts): … ctx is stale` (#377).** 宿主在会话销毁后
143
+ 仍会派发 `turn_end` / `agent_settled`,扩展入口这时读 ctx 会抛。现在把这种"ctx 已作废"当成「没有 UI」处理
144
+ (与状态栏一直以来的做法一致):缓存清空、安静返回;其它异常照旧抛出。用户感知:每次退出会话少一行红色噪音。
145
+
146
+ - **没声明状态机的队伍「全员失败」时不再静默消失 (#372).** #371 之后,没声明 `phases` 的 centurion 出生时
147
+ `nodes.phase` 为空,而「全队都失败」的兜底逻辑开头就写着「没有相位就没什么可判的」——于是这种队伍把 N 个
148
+ worker 全派失败后,账本里没有失败、没有事件、父级什么也收不到,只能等 lead 自己写 verdict。判据改成
149
+ 「这是一支有子节点的队伍」:有相位时行为逐字不变(队伍自己的机器判 `failed` 并记 `phase_change`);
150
+ 没有相位时直接写 `status → failed` 并记一条 `node_failed`(既有事件类型,闭集仍是 16 条),父级收到同样的
151
+ 通知,同一节点只通知一次。**取舍**:`status = failed` 让 `isTerminalNode` 为真,全队失败的队伍因此不再能被
152
+ 恢复——pre-#371 的 `phase → failed` 本来也是不可恢复的,两种写法现在给出同一个结论。**判决是硬的**:lead 那一轮
153
+ 仍会正常结束,收队 drain 不再把 `failed` 改写成 `settled`(以前会:账本里留下「`node_failed` 事件 + `settled` 行」
154
+ 的自相矛盾,队伍还会重新变成可恢复)。只有「全队失败」这类 supervisor 判决受保护,普通子节点的 drain 行为逐字不变。
155
+ **已经结束的队伍一律不再判定**:`retired`(会话退出时的暂停)或已经结束(`settled`)的队伍,**不管有没有声明
156
+ 相位机器**,都不会被本路径判失败——相位原样保留、不发通知、父级也不会多收一条「Team failed」,因此仍可恢复
157
+ (`legion-session-continuity-v1.md` §3)。root 行同样不受影响(它不是队伍;把 root 判失败会让寻址到 root
158
+ 的邮件全部退回)。
159
+
160
+ - **内联声明的相位机器现在也显示在 `/legion` 树里 (#372).** 面板取机器只有「按角色查角色卡」一条路,而 #371
161
+ 允许 root 在派发时内联声明 `phases`——这种队伍根本没有卡,配置得最明确的队伍反而看不见自己的相位。
162
+ 现在面板先按**节点**问一次(来源是 launch 写下的 `ChildRecord.phases`,跨进程、重开会话后都还在),查不到
163
+ 再退回按角色查卡,优先级与契约一致(内联 > 卡片 > 无)。查询按「**派发这个节点的那本 run**」定位,不是
164
+ 「谁先命中」——同一个 checkout 里每个 run 都有个 `root.team`,否则旧 run 的声明会画到今天的节点上;实在无法
165
+ 消歧时按本仓惯例当作「没有」而不是猜。只有队伍根节点会被问(其它角色不可能带声明),且每轮渲染每个队伍只问
166
+ 一次,所以渲染一整队 worker 不会变成每节点一次注册表读取。渲染形式与卡片声明完全相同:不新增列、不改布局;
167
+ 没声明的队伍依旧什么都不显示。
168
+
169
+ - **同一个 session 重开时会接回上次的树,并且只“提示”,不自动动任何子 agent (#374).** 以前每次启动都新建一份 run
170
+ 和一本 `legion.db`,上次被打断的队伍既看不见也接不回来。现在 run 记录自己这个 session 的 transcript 路径
171
+ (`RunRecord.sessionFile`),同 session 再启动时复用它——同一个 runId、同一本账本、节点与相位原样还在
172
+ (`legion_tree` 看得到),root 行是更新而不是重建。**恢复必须显式**:启动时只在 system prompt 里列出
173
+ 「上次退出时被打断的子 agent」并给用户一行提示,`spawn` 零次;用户同意后再 `subagent({action:"resume", name})`。
174
+ **换一个 session 启动则什么都不做**:不扫描、不提示、不写任何旧文件。
175
+
176
+ - **退出 = 暂停,不再判负 (#374).** `session_shutdown` 收队时不再把相位推到 `aborted`(终态会被
177
+ `CHILD_TERMINAL` 永久拒绝恢复),只把节点标成 `retired` 并记一条带 `reason: "session-shutdown"` 的
178
+ `node_retired`(事件类型仍是 16 条)。相位、`phase_since`、`rework_count` 一律不动。其它收队原因
179
+ (`stop`/`abort`/超预算/显式 retire)行为逐字不变。
180
+
181
+ - **`subagent resume` 能把声明过的相位机器一起带起来 (#374).** 声明式机器现在快照进 `ChildRecord.phases`,
182
+ 跨进程恢复时重新下发给子进程的 `PI_LEGION_PHASES`——否则复活的内联队伍会退回角色卡机器,
183
+ 自定义相位名全变 `invalid_phase`。
184
+
185
+ - **空 `legion-verify` 块不再被报告成"验证通过" (#371).** 契约里没有 ```legion-verify 块时仍然直通
186
+ (§7.3 的只读队形状,行为不变),但工具返回、收队提示与后台收尾现在都说
187
+ `by contract (0 commands)`;若声明的机器把 `verification_passed` 当收尾门,还会明说
188
+ 「门是被空契约满足的」。以前这三种输出都写着 `Verification passed`,父级无法分辨
189
+ "跑过了" 和 "什么都没跑"。
190
+
191
+ - **`centurion` 的状态机改成 root 派发时声明 (#371).** 以前「角色叫 centurion」就等于强制走五阶段:
192
+ 哪怕只改一行文件也要走契约 → 派 worker → 派 reviewer → 跑 verify → squash MR。现在只有
193
+ `subagent({ agent: "centurion", phases: [...] })` 显式声明了才走状态机(声明了 = 以前的行为一字不改,
194
+ 含 `done` 后注入的收队提示);**没声明就是一次普通派发**——`nodes.phase` 为空、工具里没有
195
+ `legion_phase`、也没有收队提示,干完自己的活交 verdict 即结束。小活请直接派 worker。`phases` 只能由
196
+ root 声明、只能给 centurion,且必须能从 start 走到某个终态,否则在创建 worktree 之前就被拒。
197
+ 同时把「非 root 不得派 centurion」写进契约(`docs/legion-v2-contract.md` REV-27,子契约
198
+ `docs/contracts/centurion-declared-phases-v1.md`),`agents/centurion.md` 的验收条目从 `required` 改为
199
+ `optional`(它从来没有可跑的验证命令)。worktree/rpc 耦合、深度公式、组员不隔离均不变。
200
+
201
+ - **`/legion` 整帧分配降到 ≤2.0MB(−59% @120×40,−67% @200×60),且可见输出逐字节不变 (#364).** #362 已经修掉「每写一格分配一个 `Cell`」,这一轮清掉的是**每帧重建的
202
+ 脚手架**:星系像素缓冲/帧内 Map、行字符缓冲、图表 band 栈、`new Canvas(W,H)`,以及 `Math.hypot`、
203
+ `{...cell, color}` 克隆、每帧 `text.trim()` 这类每格/每 run 的临时对象。现在这些都原地复用,
204
+ role 色表由 `set()` 增量维护、调色板按 **(theme, 该帧 role 集合)** 缓存,`renderLegionView` 接受一个
205
+ 可选 canvas 让 `createLegionViewFactory` 跨帧复用(`new Canvas` 语义与成本对所有其它调用方不变;
206
+ `chartWindow` 的 earliest 推导也一并去掉了 `filter`/`map`/`spread`,语义等价)。
207
+ 实测(同一脚本、200 节点、真 `Theme(dark)`、`--expose-gc`、单帧 `heapUsed` 差值 20 次中位):
208
+ 整帧分配 **2586.9 → 1036–1053 KB**(120×40/live,−59%)、**3963.9 → 1309–1342 KB**(200×60/live,−67%);
209
+ 时间仍在契约门禁内(median/p95 ≤4.0/≤7.0ms @120×40,≤7.0/≤10.0ms @200×60)。
210
+ **可见输出零变化**:4 形状(120×40 / 200×60 / 80×24 / 46×60)× `toPlain`/`toLines(真 dark)` 的 sha256、
211
+ 以及逐格 `(ch, color, bold, inverse)` 的 sha256,都与改动前捕获的 golden 逐字节相同;新增的**内联 golden**
212
+ 补上了仓里原先没有的快照空档(旧用例都是"同代码渲染两次再比",两侧同时漂移抓不住)。
213
+ 基准:`bench/perf-core/10-legion-frame-alloc.ts`(+ `10-baseline.json` digest golden);
214
+ 契约:`docs/contracts/viz-frame-alloc-v1.md`。
215
+
216
+ - **`/legion` 从全屏 overlay 改成主屏挂载,整帧合成开销消失(120×40 4.39ms → 1.69ms,200×60 9.59ms → 3.39ms)(#347).**
217
+ 页面一直以 `overlay: true` + `width:"100%"` 打开,于是 pi-tui 对**每一行**都跑 `compositeTuiLine`
218
+ (`extractSegments` + `sliceWithWidth` + `visibleWidth` 三次 `Intl.Segmenter` 字素扫描,行宽缓存按整行
219
+ key 而实时帧永不重复)。`/legion` 是这条路径的最坏输入:非空格里 56%–68% 是 braille/box/块字形,
220
+ 没有一行纯 ASCII,非 ASCII 行单价是 ASCII 的 3.67×,实测占整帧 57%–70%。
221
+ 现在改成 `ctx.ui.custom(factory, { overlay: false })`(与经典 `--tree` 视图同一个挂载方式):
222
+ 页面**替换** base 组件而不是叠在它上面,宿主的 overlay 合成层完全不参与,页面只付自己那一次
223
+ `render()`。实测(真 `TuiMainScreen` + `FakeTerminal`,`written[]` 每帧清空,50 帧 × 3 轮):
224
+ 页面帧 median 2.49 → **0.96**(80×24)、4.41 → **1.77**(120×40)、8.63 → **3.35ms**(200×60),
225
+ p95 5.30/17.64/41.51 → **1.25/2.85/4.67ms**,每帧字节 −3.1% / −2.5% / −1.8%(视觉内容等价)。
226
+ **用户可见行为不变**:`Esc`/`q` 仍然回到原来的会话视图(宿主把打开时换掉的 editor 挂回来);
227
+ 页面继续按 `terminal.rows` 自己定高(非 overlay 的自定义组件是布局叶子,宿主不分配视口高度,
228
+ 与 `createTreeViewFactory` 同源);`/legion` 内打开的详情浮层仍是 75% 浮层
229
+ (`MODAL_MAX_WIDTH_RATIO` / 65% 高 / `resolveModalOverlayOptions()` 一个像素没动),resize 语义原样。
230
+ `paintPageFloor` **保留且仍然必需**:主屏挂载后详情浮层依旧是 overlay,合成器依旧按行取 base 行尾的
231
+ SGR 快照,实测去掉 floor 后两种挂载的 gutter 都退回终端默认背景(#308 黑带重现),
232
+ 所以它不是「保险」。不改宿主 `compositeTuiLine`、不改 pi-tui 节流常量。
233
+ 基准:`bench/perf-core/09-legion-mount.ts`;契约:`docs/contracts/legion-main-screen-mount-v1.md`。
234
+
235
+ - **`/legion` 不再每写一个格子就分配一个 `Cell`:`Canvas` 预建 W×H 个可变 cell,`set()` 原地改字段 (#362).**
236
+ `set()` 原本是 `row[x] = { ch, color, ...style }`——一帧要写 4,583 次(120×40)/ 9,691 次(200×60),
237
+ 实测 52.4 B/次 与 128.0 B/次。现在格子在构造时一次性建好,写入只做 `cell.ch / color / bold / inverse`
238
+ 的显式字段赋值(无对象展开、默认 style 用共享常量),`sanitizeChar` 也按字符记忆化
239
+ (`String.fromCodePoint` 对 Latin-1 以外的字形每次都新建字符串,图表/星图画 `█ ⣿ ─` 上万次)。
240
+ 同一脚本前后实测:每次 `set` 分配 **52.4 → 0.03 B**(120×40)/ **128.0 → 0.002 B**(200×60);
241
+ 整帧分配 **2839.9 → 2551.8 KB**(120×40/live,−10%)、**4556.8 → 4018.2 KB**(200×60/live,−12%);
242
+ 帧 median **4.53 → 3.87 ms**(120×40/live,−15%)、**6.95 → 5.73 ms**(200×60/live,−18%),
243
+ p95 −12% / −13%(5 轮 × 50 帧前后交叉,各 1250 样本)。预填本身也顺带变小
244
+ (269.3 → 190.0 KB / 616.1 → 472.4 KB,53–57 → 39–41 B/格)。
245
+ **`/legion` 可见输出逐字节不变**:4 个场景(120×40 / 200×60 × live / settled)的 plain 与 ANSI
246
+ 摘要前后一致,`legion-viz` 快照用例原样绿。对外语义只有一条要说清:`get()` / `cells` 是**活视图**
247
+ (下一次 `set()` 会改同一个对象),不是快照——两个既有读点都只读 `ch` 即弃。
248
+ 另:`drawChart` 的 `(from, to)` 直接交给 `computeStats`,一帧只调一次 `chartWindow`。
249
+ 整帧分配离 #346 的 ≤2.0MB 仍差 0.5–2.0MB:残项已逐项隔离(`drawGalaxy` ≈0.93–1.23MB、
250
+ `toLines` ≈0.44–1.01MB、`drawChart` ≈0.38–0.62MB、`Canvas` 预填 0.19–0.47MB),
251
+ 见契约 §3.4 / §4 与 follow-up #364。
252
+
253
+ - **`/legion` 的 tok/s 图表一帧只分桶一次,且只算本帧真的要画的 series (#346).** `drawChart` 先给图体
254
+ 分桶、`computeStats` 又用**完全相同的参数**再分一遍,200 节点下一帧白烧 ≈1.2ms 与 ≈0.6MB 垃圾;
255
+ 同时 `RateSampler.buckets()` 会遍历**每一个曾经记录过**的 series(`series` 永无删除点),
256
+ 结束的 child 每帧照算。现在:`chartBuckets(data, count, window?)` 是唯一入口,结果以第三个参数
257
+ 传给 `computeStats(data, buckets, series?)`(不传时退回老签名,语义逐字节一致);
258
+ `buckets()` 接受 `BucketOptions.onlyNodeIds`,只遍历 `visibleRateNodeIds(data.nodes)`——settled 行
259
+ 只要还在画就照旧贡献,不在帧里的 series 保留原始采样但不参与本帧计算;同时不再为每个 series
260
+ 新建 `plotW` 长度的临时数组,并对「`changedAt` 早于首桶窗口左沿一个桶窗口」的 series 直接跳过
261
+ (`interpolate` 两端夹取 ⇒ 每桶 Δ 恒为 0,可证明为同一结果)。
262
+ `changedAt` 是 `record()` 里值真的变化时才更新的时间戳:上一版用的是**最后一个采样点**,而
263
+ flat-run collapse 每帧都会把 settled 行的最后一个采样点推到 `now`,所以那条跳过在生产形状下
264
+ 一次都没触发(实测 200 series / 190 settled ⇒ walked 200, skipped 0)。图表的 `avg` 也跟同一份
265
+ 可见集走:不可见 series 不再把它抬起来(实测 40 个已结束 ghost 把 300 tok/s 抬到 1.5k)。
266
+ 实测(120×40 / 200 节点,真 `Theme(dark)`,50 帧 × 3 轮):帧 median 6.5ms → 4.4ms(−32%)、
267
+ p95 9.1ms → 4.8ms(−40%)、`drawChart` 2.65ms → 0.6ms(−77%)、`buckets()` 1.24ms → 0.47ms;
268
+ 帧分配 4214KB → 3306KB、`drawChart` 1432KB → 530KB。尾部 60s 窗口形状下 190 个 settled series
269
+ 现在真的被跳过(walked 200 → 10,`buckets()` 0.89ms → 0.10ms);**整轮窗口下它们仍在窗内、
270
+ 必须照画**——「结束的 child 不再每帧收税」这句上一版契约的话是错的,已在契约 R2 如实改写。
271
+ 图表外观与 `/legion` 其余输出不变。契约:`docs/contracts/legion-chart-frame-buckets-v1.md`。
272
+
273
+ - **`finished` 完成结果缓存有了有界淘汰:每条 ≤8KB、合计 ≤1MB 驻留 output 文本,淘汰只丢文本、留结果 (#349).**
274
+ 这个 one-round 缓存原本为每个完成过的 child 留一份**输出全文**且永不淘汰——实测 50 child ×
275
+ 204,800B ⇒ GC 后驻留 10.07MB,长会话按 child 数单调增长。现在按 LRU:单条驻留 ≤8KB
276
+ (`FINISHED_CACHE_MAX_ENTRY_BYTES`)、合计 ≤1MB(`FINISHED_CACHE_MAX_OUTPUT_BYTES`)、
277
+ 条目数 ≤128(=1MB/8KB,`FINISHED_CACHE_MAX_ENTRIES`),超出的条目剥成 4000 字符预览,
278
+ 但**条目本身与 `runId`/`round`/`execution`/`acceptance`/`outputFile` 全部保留**——删条目会把
279
+ `collect`/`wait` 变成 `missing`(结果丢失)并抹掉本轮的 claim 键。命中时从
280
+ `snapshot.outputFile` 重新读回全文,因此 `wait`/`collect` 在淘汰前后返回**同样的结果**
281
+ (文本等价到 `output.trim()`)。缓存条目是**快照的浅拷贝**:淘汰只动缓存自己的那份,
282
+ 已经交给调用方(以及 `join.onTerminal` 读的同一个对象)的结果永远不会被事后改写;
283
+ 上一版存的是引用,插入时就把 >4MB 的输出原地剥成了 4000 字头(首次 `wait` 拿不到全文)。
284
+ 没有 `outputFile` 的条目(宿主自备 collect、写盘失败)是 **pinned**,按契约不淘汰,字节数由
285
+ `noticeQueueStats().finished.pinnedBytes` 报出(新增 `finished` 计数块:`entries` /
286
+ `residentBytes` / `stripped` / `pinnedBytes`);盘上文件被删时降级为「4000 字符头 + 元数据 +
287
+ `CollectSnapshot.outputTruncated: true`」,永远不是 `missing`。实测:50 child × 204,800B 驻留
288
+ 10.07MB → **0.51MB**(满足 #349 的 ≤1.0MB),重新 track 全部后回落到基线。
289
+ 契约:`docs/contracts/finished-cache-eviction-v1.md`。
290
+
291
+ - **A 500ms status refresh no longer re-parses a session file that has not been written since the
292
+ last tick (#348).** `refreshUi()` → `statusEntries()` parsed every child's session file — plus every
293
+ historical ledger row that carries one — once per tick, changed or not: one idle child cost 119
294
+ opens per 60s, 50 children 6000. That count grows with the *length* of a session rather than with
295
+ the number of live agents — the reason the issue suspected the 64-entry incremental-parse cap of
296
+ thrashing every tick. `parseSessionFileCached` now sits in front of the parser: one `statSync`
297
+ compares `(path, size, mtimeMs)` and an unchanged file is answered from the previous parse instead
298
+ of opening it at all. A write from another process is still seen — an append grows `size`, a
299
+ rewrite moves `mtimeMs` even at identical length — so no fs watcher and no bus subscription is
300
+ involved, the one exception being a same-millisecond rewrite that keeps the byte length (see the
301
+ function's doc: the parser's own head/boundary fingerprint would catch it, but only by opening the
302
+ file, which is the fixed cost this gate exists to remove; it resolves on the next write). The
303
+ display readers go through the gate (`progressFromSessionFile`, which every status row calls;
304
+ `/legion summary`; `/legion timing`), while every path that has to *decide* something about a file
305
+ keeps calling `parseSessionFile` on the file it is judging: verdict derivation (`orchestrator`,
306
+ `rebuild`, `legion_*` tools) and the postman's DELIVERED evidence scan — that one moves a message
307
+ to DELIVERED and can discard a notice as obsolete, and the frozen criterion names the real parse,
308
+ so it is **deliberately kept** off the gate (it also only parses a seam whose mailbox page is
309
+ non-empty, so a seam with nothing pending parses nothing). None of the parsing semantics moved.
310
+ A file that cannot be *read* (`EACCES`/`EMFILE`/`EIO`) is never cached: `parseSessionFile` answers
311
+ that the same way it answers an empty file, and a settled session file never moves
312
+ `(size, mtimeMs)` again — `chmod` only bumps `ctime` — so caching that empty value would have
313
+ pinned `/legion summary` and the status rows at zero for the rest of the process. Both caches are
314
+ bounded: the file-level cache is capped at 256 entries, LRU-ordered on every hit, so the files a
315
+ tick actually visits are the ones that stay; the incremental cap rises 64 → 256 as headroom for a
316
+ child that appends on every tick, **not** as the source of this change's win (that cache's own
317
+ `misses` counter read 0 throughout the windows below, so it was never the cost being paid).
318
+ `sessionFileCacheStats()` (`hits`/`misses`/`size`/`statCalls`) exists for tests and is never
319
+ rendered.
320
+
321
+ Measured with the issue's own script (`b3.ts` harness, real 60s/20s windows): 1 idle child 119 → 0
322
+ opens, 50 children 6000 → 0, 100 session files 4000 → 0, and the only syscall left per unchanged
323
+ file is the `stat`. Two caveats on those zeros. First, they are window figures: the window starts
324
+ after the seed parse, so the steady-state number is **one parse per session file per process**
325
+ (plus one per write), not zero — a window that opens on the first tick does pay for it. Second, the
326
+ CPU side of that table (0.34% → 0.10%, 3.64% → 0.31%, 16.20% → 0.63%, `process.cpuUsage()` over the
327
+ same windows) was sampled on runs whose loadavg differed, so it shows direction, not an isolated
328
+ cost. The microsecond figure is the gate's own micro-benchmark — open + fstat + two 256B fingerprint
329
+ reads + close, 36–66µs warm, timed in isolation by the issue — and must not be subtracted from the
330
+ logged per-parse cost: that log shows a whole parse at ≈1.19ms per parse in one run and ≈0.78ms per
331
+ parse in the other, i.e. the per-parse figure itself moved between the two runs.
332
+
333
+ - **A control call no longer scans the run registry to find a child it is already scoped to, and the
334
+ scan now keeps a snapshot when it does run (#344).** Every `collect` / `status` / `steer` / `abort` /
335
+ `retire` / `continue` opened with `store.listRuns()` — `readdir` plus one `readFileSync` +
336
+ `JSON.parse` per run — *before* it even asked which run it was scoped to, so a call that already
337
+ knew its `runId` (the explicit argument, the session's legion run, or the run the runtime tracks
338
+ the name under) paid for every run ever written: 26ms at the 352 runs on the reporting machine,
339
+ 97% of a warm `status`, 110ms at 1000 synthetic runs, linear and unbounded apart from the 14-day
340
+ retention prune. The scan is now a FALLBACK: the scope run is read with one `readRun` (0.05ms) and
341
+ the registry is consulted only when the name might live in another run — which is exactly the
342
+ #312 case that must report or refuse instead of adopting. On this repo's 365-run corpus one name
343
+ resolution went 18.4ms → 0.04ms, and on 1000 synthetic runs 31.5ms → 0.04ms, with **zero**
344
+ `listRuns` calls for a scoped hit. The fallback scan itself is cached per runs directory: a
345
+ process-wide snapshot (shared by every `RunStore` instance on that root, since the tool surface
346
+ builds one per call) invalidated by the directory's `mtimeMs`, by any write this process performs
347
+ (`createRun` / `updateRun` / `updateChild` — all funnel through `writeRun` — plus `prune`'s explicit invalidation), and, for a
348
+ record another process rewrote in place with no directory mtime to show for it, by a 1s TTL that
349
+ tests drive through the store's injectable clock. Cold scans are unchanged (21.2ms at 365 real
350
+ runs, 34ms at 1000 synthetic on both sides of this change); a repeat scan is 0.018ms / 0.012ms.
351
+ `listRuns()` still answers exactly what it did — every readable run, oldest first, corrupt entries
352
+ skipped — and every #312 strict-scope case (`collect`/`continue`/`resume` refusing a same-named
353
+ node elsewhere, ambiguity never resolved by preference) is pinned by a parity test that compares
354
+ the shortcut against the full scan shape by shape.
355
+
356
+ - **A launch that cannot use a worktree no longer runs the nested-worktree `git` probe whose answer is
357
+ then discarded (#345).** `isInsideChildWorktree(cwd)` — one synchronous `git rev-parse
358
+ --git-common-dir`, ~5–7ms — was an *argument* to `resolveLaunchWorktree`, so it was evaluated before
359
+ the resolver could short-circuit, and for every non-centurion its value was then thrown
360
+ away: the worktree guard forces `worktree: false` for those roles whatever the probe said (all 18
361
+ combinations of `roleDefault × launch × parentInWorktree`, now a table-driven test). The probe is
362
+ asked on the centurion branch only — the one branch it can change — so a worker launch
363
+ drops from 1 `git` subprocess to 0 and from 11.6ms to 2.9ms median on this repo, while a
364
+ centurion launch still spawns the same two git commands (probe not reached: legion
365
+ forces its worktree, `worktree add` still runs). `resolveLaunchWorktree`'s result semantics are
366
+ untouched; the one observable difference is that a role card pinning `worktree: true` while the
367
+ parent sits in a linked worktree now also gets the existing one-time `worktree denied for
368
+ non-centurion role` warning, because the suppression that used to silence it is the
369
+ answer being skipped.
370
+
371
+ - **`messages.delivered` is a five-value domain now — `pending / accepted / bounced / delivered / obsolete`
372
+ — and `LEGION_DB_USER_VERSION` moves 1 → 2 (#322).** The old `INJECTED(1)` claimed a delivery the ledger
373
+ never observed: it was written the moment a send call returned, which covers `queued` (waiting for the
374
+ child's next turn boundary), `accepted` (a turn started) and `intercepted` (an input handler ate the text
375
+ and the child never saw a word of it) alike, so a parent had no queryable signal to tell them apart.
376
+ `ACCEPTED(1)` now says only "the send call returned", `DELIVERED(3)` is written later from evidence, and
377
+ `intercepted` bounces instead of pretending. The migration adds **no DDL** — the column was already
378
+ `INTEGER NOT NULL DEFAULT 0`, so v2 is a semantic agreement plus a version bump, and every v1 `1` reads as
379
+ `accepted` with no backfill. `MAIL_DELIVERY.INJECTED` survives as a `@deprecated` alias of `ACCEPTED`
380
+ so out-of-tree imports keep compiling; the ledger's own call sites use the new names, and exactly one
381
+ unit test keeps the alias pinned (`INJECTED === ACCEPTED`) instead of letting it rot silently. Events are
382
+ unchanged on purpose (the closed set stays at 16): `ACCEPTED` reuses the `mail_sent` row `sendMail`
383
+ already wrote, `DELIVERED` reuses `mail_delivered`, `OBSOLETE` reuses `mail_bounced` with
384
+ `reason: "obsolete_after_phase_change"`. A read-only `mailboxStats(db, nodeId)` exports the five counts;
385
+ nothing renders it yet — D5 asks for the export and its unit test, not for a caller. Contract: `docs/contracts/mailbox-delivery-v1.md`,
386
+ `docs/legion-v2-contract.md` §8.3.1/§8.5 (REV-23).
387
+
388
+ - **`SessionRuntime.consumeCollect*` now answers with a `ClaimedCollect`, not a bare snapshot
389
+ (#320/#327).** The claim-once seam claims a terminal result as it hands the snapshot out, and
390
+ the caller has to be able to hand that claim back when it bails — which requires knowing
391
+ whether *this* call took it (`src/api.ts` re-exports `SessionRuntime`, so this is a public
392
+ surface change, and it is a breaking one):
393
+ ```ts
394
+ const { snapshot } = await rt.consumeCollect("worker-0")!; // was: const snapshot = await …
395
+ ```
396
+ `claimedNow` is `true` only for the call that took the round's claim; a caller that delivered
397
+ the text to nobody rolls it back with `unclaimUndelivered(name, snapshot, claimedNow)`, and a
398
+ call that did not take the claim leaves the real delivery's claim alone. **This breaks
399
+ `zzjcool`/api consumers**: the bare-snapshot shape *did* ship — v4.0.0 and v5.0.1 both exported
400
+ `consumeCollect(name): Promise<CollectSnapshot> | undefined`, and `src/api.ts` calls that surface
401
+ "safe to depend on" — so the release that carries this change must be a **major**. The migration
402
+ is the one line above; no compatibility shim is provided (no overload, no `snapshotOnly`
403
+ switch), because a second shape would keep the claim/copy ambiguity alive for everyone. Contract
404
+ revision: `docs/contracts/notice-claim-v1.md` (D2).
405
+
406
+ - **`AbortOutcome` now carries the bound the abort really applied (`timeoutMs`), and the timeout
407
+ receipt reads it instead of a default of its own (#325).** `abortReceipt` used to format a
408
+ `timeoutMs = DEFAULT_ABORT_TIMEOUT_MS` parameter that nothing ever passed — the tool surface
409
+ deliberately takes no bound (it is a `wait`-only parameter) — so the receipt could only ever say
410
+ "5s", even for a caller that armed a different limit on the supervisor. Both backends now report
411
+ the normalized, timer-capped bound in the outcome for all three statuses, and the receipt names
412
+ that number. The receipt text on the tool surface is byte-identical (no bound is ever passed
413
+ there); the two private `race*Timeout` helpers the backends each carried are one shared
414
+ `raceWithTimeout` (`src/supervisor/race-timeout.ts`) with the timer cleanup, loser-rejection
415
+ swallow and timer-delay cap unchanged, and the in-process pre-abort queue clear now runs inside
416
+ the child's own identity scope (`runAtAbsoluteDepth` + `runInChildContext`), like the interrupt
417
+ it precedes. Contract revision: `docs/contracts/abort-soft-interrupt-v1.md` (D2/D7).
418
+
419
+ ### Fixed
420
+
421
+ - **退出会话时不再出现 `Extension error (<boundary>): turn_end could not resolve the persisted
422
+ assistant entry ID` (#383)。** 这是 pi 宿主在 `turn_end` 边界上的既有缺陷(`dispose()` 之后仍会收尾一个
423
+ 被 abort 的 turn,message 没落盘),扩展这边唯一能做的是**不去触发那个边界**:宿主只在
424
+ `hasHandlers("turn_end")` 为真时才走这条路径。我们注册的 `turn_end` 只做一次 `bindUi`,而 `bindUi` 已经挂在
425
+ `turn_start`/`agent_start`/`agent_end`/`agent_settled`/`input`/`tool_result` 上,于是直接不注册——宿主连报错
426
+ 分支都进不去,UI 行为不变。
427
+
428
+ - **没有事件总线时账本不再漏记事件 (#380)。** 四处写成 `eventBus?.emit(appendEvent(...))`:可选链在没有
429
+ 订阅者时会把 `appendEvent(...)` 的参数一起短路,于是 headless 宿主(不传 eventBus 的嵌入方)里相位迁移
430
+ 只改了 `nodes.phase`、账本里一条 `phase_change` 都没有,重开账本时历史与真实状态不一致。改成「先写账本、
431
+ 再通知订阅者」;交互模式行为不变。
432
+
433
+ - **PR #363 review follow-up: an internal notice can no longer be refused into silence, and a refused
434
+ one is retried.** #353 made `sendMail` throw for a `U+0000`, which is right where the text is the
435
+ model's own (`legion_mail`) and wrong at the three call sites that compose a notice from ledger
436
+ facts, because all three run after the move they describe. `requestLegionPhase` writes
437
+ `updateNode` + `phase_change` and only then calls `notifyParent`, so a `reason`/`note` carrying a
438
+ NUL (model text, and the only place one can exist — a ledger column would have truncated it on
439
+ write) turned an accepted transition — including a declaring centurion's failure notice, the #309
440
+ promise — into a tool error while the team had already moved; `checkOwnPhaseTimeout` wrote its
441
+ `phase_timeout` row (also the de-dup key) BEFORE mailing, and `LegionPostman.start()` swallows
442
+ whatever escapes (`.catch(() => {})`), so a refused notice was lost for the whole stint. The
443
+ refusal now belongs to the tool boundary alone: `legion_mail` answers `toolError(…,
444
+ "MAIL_INVALID_TEXT")` so the code is visible in `details.error` instead of only in a message, while
445
+ internal notices degrade — the NUL characters are dropped and the send retried, and a refusal the
446
+ ledger understands (rate limit, permission) falls back to `sendRootMessage` + a warning — and never
447
+ throw. `checkOwnPhaseTimeout` places the notice before writing the de-dup key, so a notice it could
448
+ not place anywhere leaves the key unwritten and the next tick tries again; 0 keeps its meaning and
449
+ the built-in subjects/clocks are untouched.
450
+
451
+ - **PR #363 review follow-up: a failed revival gives the phase clock back, and the completion gate's
452
+ assumption is pinned.** `Orchestrator.reserveNode` moves `phase_since` on a revive (#357) and
453
+ records the `revived` `phase_change` before the supervisor spawns; a spawn failure rolled the
454
+ reservation back but left the moved clock behind — a stint `checkOwnPhaseTimeout` de-dupes on, for a
455
+ launch that does not exist. `phase_since` now travels with the rest of the rollback. Also: the
456
+ derivation of the success phase follows the ORDER A CARD DECLARES, so "`phases:` order is pipeline
457
+ order" is a load-bearing assumption rather than a style note — a case now pins a shuffled
458
+ declaration moving the gate, and its control pins that a non-final phase cannot collect a team.
459
+ Contracts: `docs/legion-v2-contract.md` REV-26 and
460
+ `docs/contracts/declarative-phase-machine-v5.md`.
461
+
462
+ - **`legion_mail` refuses text containing `U+0000` instead of storing a truncation (#353).** SQLite's
463
+ TEXT binding stops at the first NUL, so a `body`/`subject` carrying one was written as the part
464
+ before it while the send still answered `ok: true` — and `bodyBytes` counted the ORIGINAL text, so a
465
+ message that arrived cut in half looked complete to its sender. There is no lossless encoding that
466
+ keeps "the body" meaning the same thing to every reader, so the send is refused before any byte is
467
+ counted: `sendMail` throws `MailInvalidTextError` (code `MAIL_INVALID_TEXT`, a member of the existing
468
+ `code` vocabulary) naming the field, the character offset of the first NUL, and that NUL is not
469
+ supported. `subject`, `body` and every `to` token are checked; nothing is stored and no `mail_sent`
470
+ event is written. NUL-free text is untouched, and `bodyBytes` keeps describing exactly the row that
471
+ was stored (the invariant the truncation broke).
472
+
473
+ - **A team woken by `continue` starts a new phase stint, so its next timeout is announced again
474
+ (#357).** The `Phase timeout:` notice was already judged `OBSOLETE` at settle time (#333/#354); what
475
+ made the team go permanently silent is that `Orchestrator.reserveNode`'s resume branch only rewrote
476
+ `status`, leaving `phase_since` at the value of the stint that had ALREADY timed out. Since
477
+ `checkOwnPhaseTimeout` dedupes on exactly that value (`hasPhaseTimeoutForSince`), one `phase_timeout`
478
+ per stint meant one for the life of the team: the second stint could run past its advisory clock
479
+ forever and nobody would hear. A revival now advances `phase_since` to the revival clock and records
480
+ a `phase_change` with `from === to` and `reason: "revived"` — the phase does not move (work resumes
481
+ where it was), the event set stays closed (no new type, no new subject prefix, no change to the
482
+ `kind: "notice"` accounting), and a revival that cannot move the clock still dedupes, because the key
483
+ is `phase_since` and not "a revival happened". Contract note:
484
+ `docs/contracts/mailbox-delivery-v1.md` (D4).
485
+
486
+ - **A declared machine's own phase names now reach the verifying gate and the phase clock (#358).**
487
+ The follow-up left by #350: `finalizeVerifiedTeamIfReady` hard-coded `node.phase !== "verifying"` and
488
+ evaluated `verifying → done` with no `machine`, so a workflow the card called `spec → impl → ship`
489
+ could never take the supervisor's verified-completion path (`invalid_phase`, dropped by the
490
+ `!decision.allowed` guard). The completion gate is now the phase of THIS team's machine that judges
491
+ success — an exit to `done`, preferring one gated on `verification_passed`, otherwise the last
492
+ declared candidate, which is `verifying` in the built-in machine — and the machine is forwarded to
493
+ `evaluatePhaseTransition`. A team with no declared machine (or a machine with no exit to `done` at all)
494
+ keeps the historical literal, so the built-in four-phase pipeline is unchanged. The sibling hole is
495
+ the same mistake in the clock: `checkOwnPhaseTimeout` resolved `DEFAULT_PHASE_TIMEOUTS[phase] ?? 0`,
496
+ which for an undeclared phase name is 0, so `PhaseDefinition.timeoutMs` (`machineTimeout`) was dead
497
+ code and a custom phase's advisory clock never armed. The order is now operator setting
498
+ (`legion.phaseTimeouts`) → the machine's `timeoutMs` → the built-in default → 0, and 0 keeps its
499
+ pre-existing meaning of "never warn". **Correction (PR #363 review, major 1):** that order was only
500
+ real for phase names the built-in table had never heard of. `index.ts` handed the postman a
501
+ default-FILLED `legion` object, so `settings.phaseTimeouts[phase] ?? machineTimeout(...)` always had
502
+ a value on the left and a card's `timeoutMs` on `planning`/`implementing`/… was ignored in silence
503
+ (probe: `implementing` 3600000 in settings, 300000 on the card → 3600000 won). The explicitness the
504
+ settings parser already records (`legionExplicitFields`) is now carried through the run-time fill
505
+ (`legionSettingsForRun`), so "operator wins" means "the operator wrote it down". Declaring
506
+ `timeoutMs` on a built-in phase name now really takes effect; an explicit operator setting still
507
+ beats the card, and with neither the four built-in numbers are unchanged. Contract: REV-26.
508
+
509
+ - **A team is now born in the phase its own machine starts in, so a card declaring `phases:` no longer
510
+ strands its team at spawn (#350).** `Orchestrator.reserveNode` wrote a hard-coded `phase: "planning"`
511
+ into every team row, ignoring the machine the card already carried. With a machine whose first phase
512
+ is not `planning` (e.g. `recon -> build`) the team was born in a phase its own machine had never heard
513
+ of, and `evaluatePhaseTransition` refuses every transition out of an unknown `from` as `invalid_phase`
514
+ *before* the target, the outgoing edge or the `failed` escape hatch is consulted — so advancing,
515
+ declaring failure (#309) and aborting were all closed, leaving a team that was alive and spending
516
+ budget while it could not move. The derivation now lives in one function, `phaseStart(machine)`
517
+ (`machine.start ?? machine.phases[0]?.id`), shared by the state machine's entry rule, the birth site
518
+ and the load-time validation (`validatePhaseMachine` no longer keeps a third copy), so the three
519
+ cannot drift; an empty machine falls back to the built-in start (it cannot be declared — the loader
520
+ rejects it). A card that declares no machine keeps the literal `"planning"`, so the built-in
521
+ four-phase pipeline is unchanged. Contract:
522
+ `docs/contracts/declarative-phase-machine-v5.md:107` (that file is `Status: draft`). **Review
523
+ follow-up:** making a declared start reachable also made every supervisor-side net that only speaks
524
+ the built-in vocabulary reachable-and-inert. `failTeamIfAllChildrenFailed` (`legion-tools.ts`)
525
+ evaluated `from: "recon" -> "failed"` for "every child failed" **without the team's own machine**, so
526
+ the transition was `invalid_phase` and the `!decision.allowed` guard dropped the verdict in silence —
527
+ a squad whose children had all died never reached `failed` and its parent never heard; it now forwards
528
+ the machine, and a new case pins the verdict (`phase === "failed"`, one `phase_change`, one mail).
529
+ The abort half of the same hole is #351, which landed upstream in `abortLegionPhase` (already on
530
+ master), so this branch only had to stop duplicating it. The other two custom-phase holes — a hard-coded
531
+ `phase !== "verifying"` completion gate and a phase clock that resolves to 0 for undeclared names —
532
+ are documented in place and tracked as #358 rather than fixed here; this branch closes them (see the
533
+ #358 entry above).
534
+
535
+ - **A `wait` whose result the abort net throws away now hands the completion claim back, so the child's
536
+ finished notice is no longer lost (#352).** Waiting on an already-settled child goes through
537
+ `waitAction`'s finished-cache probe, which claims the round (`claimedNow === true`) — and when the
538
+ parent turn ends inside the wait's `retire` teardown, the tool layer replaces the rendered result with
539
+ the frozen `interrupted (parent turn ended; children still running)` line and nothing was handed back:
540
+ `deliverBatch` filters a claimed round, so that completion notice never reached the parent, silently
541
+ and for the whole round. `waitAction` now carries the claims IT took out as `details.waitClaims` (only
542
+ `claimedNow === true`; `waitOne`'s own `claimReported` is a no-op `false` on this path), and the bail
543
+ gives each one back through `unclaimUndelivered` — never `unclaimReported`, which would also drop the
544
+ queued copy, the only remaining route — exactly as `collectChild` already does on its own bails. The
545
+ frozen `interrupted …` text, the `(runId, name, round)` claim key and `deliverBatch`'s single exit are
546
+ untouched. Contract: `docs/contracts/notice-claim-v1.md` (bail-gate enumeration extended). **Review
547
+ follow-up:** the probe answers ASYNCHRONOUSLY, and the first shape (`if (claims.length > 0)`) was a
548
+ no-op in exactly the window #352 was opened for — `waitOne` can bail while the probe's `ensureCollect`
549
+ is still in flight, and the claim then lands on an array nobody can reach. `details.waitClaims` is now
550
+ mounted unconditionally (one probe = at most one claim), the probe's own promise travels as
551
+ `details.waitClaimsPending`, and `releaseWaitClaims` awaits it before rolling back — so a claim that
552
+ landed after the bail is still handed back and the round's notice arrives exactly once.
553
+
554
+ - **A `Phase timeout:` notice no longer outlives a team that ended in the very phase it timed out in
555
+ (#354).** `isStalePhaseTimeoutNotice` asked `isTerminalNode`, which reads `status === "failed" ||
556
+ phase ∈ {done,failed,aborted}`. A team that only investigated never calls `legion_phase`, so it keeps
557
+ its birth `planning` phase and ends with `status: settled` / `retired` — neither predicate fired and
558
+ `phase_since` never moved either, so an `action-needed` asking root to extend-or-abort a team whose
559
+ work had already merged was delivered ~30 minutes late. The "has ended" arm now asks the new narrow
560
+ `hasEnded` predicate (terminal **or** settled/retired); `isTerminalNode` is deliberately unchanged
561
+ because mail bounce and the orphan scan share it. The `reason` stays `obsolete_after_phase_change`
562
+ (event set still 16) and the "no `phase_timeout` event → stay ACCEPTED, never guess" clause stands.
563
+ Contract: `docs/legion-v2-contract.md` §8.3.1, `docs/contracts/mailbox-delivery-v1.md` D4.
564
+
565
+ - **`retire()` / `retireAll()` / `/legion stop` can no longer leave a team in its custom phase "forever"
566
+ (#351).** `Orchestrator.abortLegionPhase` evaluated the abort without the team's own phase machine, so a
567
+ card-declared name (`build`) fell through to `DEFAULT_PHASE_MACHINE`, was refused `invalid_phase`, and
568
+ that refusal was silently dropped (`if (!decision.allowed) return;`) — no `aborted`, no event, no log,
569
+ even though the child process had already been killed. The abort now forwards the machine resolved from
570
+ the node's role card, and when no machine can resolve the phase the retire is **forced** through:
571
+ `phase` becomes `aborted` (a parent retire must always take effect — the safety bottom line in
572
+ `phase-machine.ts`), one `phase_change` event carries `{ forced: true, reason:
573
+ "parent_retire_unresolved_phase", denied }` (no new event type; the set stays at 16), and one
574
+ `console.warn` names the node, the phase and the refusal.
575
+ Contract: `docs/contracts/declarative-phase-machine-v5.md`.
576
+
577
+ - **`/legion doctor` stopped recommending the dead extension switch (#329).** `inheritExtensions: true`
578
+ and `PI_LEGION_CHILD_INHERIT_EXTENSIONS=1` outlived #328's clean-up in `legion-doctor.ts`: `true` has been
579
+ the default since #280 and both yield 0 extensions when host discovery fails, so every doctor fix and
580
+ next step now names only `models.json` / `PI_LEGION_CHILD_EXTENSIONS=<path>`, with a case that pins it.
581
+ The `?fresh=1` half of the #317 case re-evaluated the wrong module (ESM drops the query when resolving a
582
+ relative import), so it now re-evaluates `extension-inheritance.ts` and asserts that a second instance
583
+ reads the shared discovery holder.
584
+
585
+ - **The `/legion` track view now paints its own page background, so the detail modal no longer sits
586
+ on a black vertical stripe (#308).** Opening a node's context viewer put a pure-black band between
587
+ the frame's right edge and the terminal's right edge (the left side looked fine). The frame was not
588
+ overflowing — `paintModalFrame` emits exactly the rect width every row. The base row was: the canvas
589
+ pre-fills `W×H` spaces and `renderRow` only styles a run when it has a color *and* non-blank text, so
590
+ a row ends in bare spaces with no SGR at all. pi-tui's compositor inherits the styling of the area
591
+ *beside* an overlay from a one-shot snapshot of the active SGR at the first grapheme after the rect
592
+ (`extractSegments`), and an empty snapshot means the terminal's default background. Every `/legion`
593
+ row is now wrapped in `theme.bg?.(LEGION_PAGE_BG, line)` at the view's single render exit
594
+ (`paintPageFloor` in `src/tui/viz/view.ts`), so the inherited state is the page floor. `theme.bg` is
595
+ optional in `VizTheme` and the call is optional, so a theme without background tokens (every unit-test
596
+ fake) still renders the exact bytes it did before, and the row width is unchanged (the wrap is
597
+ zero-width escapes only). The token is `toolPendingBg` — the only one of the seven `ThemeBg` keys that
598
+ is a neutral surface rather than a hued panel (blue / violet / yellow / green / red for the rest).
599
+ **Not fixed in the `system` theme:** with no terminal color report that theme assigns `""` to all
600
+ seven `ThemeBg` keys, so each renders as `\x1b[49m` (terminal default) and the band stays black —
601
+ upstream exposes no renderable page-background token. The new regression test keeps ANSI (the #193
602
+ `wipedRows` criterion strips escapes, which is why it never caught this) and compares the background
603
+ SGR of both gutters with the base's own: no fix ⇒ every gutter snapshot is `""`. Contract:
604
+ `docs/contracts/legion-page-floor-v1.md`.
605
+
606
+ - **The five-value mailbox delivery landed against an adversarial review: four majors and six minors
607
+ (#338, follow-up to #322/#333).** The important ones were silent: mail addressed to **root** could never
608
+ be confirmed — `nodes.session_file` was written only on the child path (`reserveNode`), so every
609
+ `to: "parent"` letter, including the whole #333 `Phase timeout:` path, sat at `accepted` forever.
610
+ `createRootLegion` now records this process's `ctx.sessionManager.getSessionFile()` on the root row at
611
+ creation (and the contract says where that stops: a ledger rebuilt from `run.json` still has no root
612
+ session file, so those letters honestly stay `accepted`). The `DELIVERED` criterion also dropped its
613
+ "or the user turn equals the mail **body**" branch — a body carries no message id, so a task card, a
614
+ different letter or a quoted paragraph matched it, and "that sentence appeared somewhere" is a mention,
615
+ not a read. And the per-tick 20-message confirmation window now **rotates per seam** (`id > cursor`,
616
+ wrapping at the tail) instead of re-reading the oldest 20: the old shape let 20 undecidable messages hold
617
+ the window forever, which stops the confirm *and* obsolete paths permanently — a deadlock, not the delay
618
+ the docs claimed. The `obsolete` rule no longer names a `kind: "instruction"` that never existed (only
619
+ `kind: "notice"` is eligible), `mailboxStats` is documented as an export with no consumer instead of a
620
+ wired status surface, each seam's session is parsed once per tick instead of once per message, and a
621
+ delivery event is re-emitted by `messageId` rather than by scanning the newest 100 rows of that node+type
622
+ (which went silent on a long-lived ledger). An intercepted send is not a steer, so it emits no
623
+ `node_steered`; the observable signal is the `mail_bounced` with `reason:
624
+ intercepted_by_input_handler`. No ledger event type was added — the closed set stays at 16.
625
+
626
+ - **A `Phase timeout:` notice that arrives after its phase ended is no longer handed to the parent as a
627
+ live to-do item (#333).** A team that runs two or three review rounds reliably exceeds the 30-minute
628
+ `reviewing` limit, so an `action-needed` phase-timeout notice is not an edge case: the notice was queued,
629
+ the team went on to converge (next phase, failure, or retire), and *then* the notice reached root, asking
630
+ for a decision about a node that no longer existed. Nothing ever revoked it — there was no state to revoke
631
+ it into. Mail whose `kind` is `notice` and whose subject starts with `Phase timeout:` is now checked
632
+ against the ledger fact that produced it (the `phase_timeout` event's `phase`/`phaseSince`, never the
633
+ notice's prose) and marked `obsolete` when the sender has left that phase or gone terminal. The check runs
634
+ in two places: **before delivery** (a queued notice is never injected at all — the path the issue actually
635
+ took) and in the postman's confirmation pass for mail already `accepted` (that one only relabels; it
636
+ cannot unqueue text a recipient already holds). Mail of any other kind — `question`/`report`/`handoff`, the domain has
637
+ no `instruction` — is never auto-obsoleted: whether a question or a handed-over task went stale is a
638
+ human call, and a `delivered` message is never relabelled, because a
639
+ consumption that happened is a fact about the past. No new event type: the verdict is `mail_bounced` with
640
+ `reason: "obsolete_after_phase_change"`.
641
+
642
+ - **The five `steer`-receipt residuals (#323).** The three honest dispositions from #313 are now
643
+ wired through the edges that still flattened them. The in-process backend can never answer
644
+ `queued` vs `accepted` — the SDK's `AgentSession.prompt()` returns `void` — so the supervisor seam
645
+ gained an explicit capability (`capabilities.promptDisposition`: in-process `false`, rpc `true`,
646
+ and the routing facade reports the intersection, `false`) instead of inventing a disposition that
647
+ backend cannot observe; the v2 contract now states that `accepted` on in-process means only "the
648
+ call did not throw". An intercepted mailbox `action-needed` steer no longer flips the node from
649
+ `blocked` to `running`: #338 already makes that send `bounced` and returns before the unblock, and
650
+ the orchestrator's own `clearBlocked` path now refuses it for the same reason — nothing landed, so
651
+ the child is still waiting. `abandonTurn` keeps the same `retiring` guard `updateTurnState` has, so
652
+ a concurrent rollback can no longer rewrite a retiring child as `idle`. And an intercepted *launch*
653
+ prompt now fails with the new deterministic code `PROMPT_INTERCEPTED`, which `canFallbackStart`
654
+ refuses — a swallowed prompt used to re-spawn one process per fallback model and roll each one back
655
+ right away. The mailbox `prompt` branch keeps no disposition by construction; its comment and the
656
+ contract now say why. Contract: `docs/legion-v2-contract.md` §3.1.
657
+
658
+ - **The in-flight tool fact behind `status` is a stack, so nested and parallel calls stop disappearing
659
+ (#331).** `working · bash 00:07:31` used to read a single slot: `tool_execution_start` overwrote it
660
+ and the matching `tool_execution_end` cleared it. Two shapes therefore made the line vanish while a
661
+ tool was demonstrably still running. A nested call (`codemode` → `bash`) pushed the inner frame into
662
+ the slot, so the inner end wiped the outer call — `status` showed nothing while `codemode` ran on. A
663
+ parallel batch kept only the newest call, so when that one finished first the still-running sibling
664
+ became invisible. Each `ChildRecord` now keeps a LIFO stack (`ChildActivity[]`): a start pushes, an
665
+ end removes **only** the frame it names (by `toolCallId`), `agent_settled` empties it, and the line
666
+ reads the top (innermost) frame for the tool name and the clock. When the stack is deeper than one,
667
+ the line names the context instead of hiding it — ` (inside codemode ×2)` for nesting (from the
668
+ event stream's own `parentToolCallId`) and ` (+N more)` for parallel siblings, with the weaker
669
+ `(+N more)` chosen when parent ids cannot tell the two apart. An empty stack renders no line, exactly
670
+ as before. Display only: `liveness()`, heartbeats, the orphan scan and every kill/abort/retire path
671
+ are untouched. The seam gained an optional `inFlightToolStack` next to `inFlightTool`, so a doubled
672
+ supervisor that only answers the single frame keeps working. Contract revision:
673
+ `docs/contracts/stall-visibility-v1.md` (D1/D2/D4/D7).
674
+
675
+ - **One name, one node: `status` / `collect` / `stop` / `continue` now resolve a child name the same way.**
676
+ In a single root session, `status(name)` and `collect(name)` called ten seconds apart answered with two
677
+ different nodes for the same `worker-0`: `status` went through `pickChildByName`, which walks **every**
678
+ run in the store and prefers a live record and then the newest `createdAt`, while `collect` acted on one
679
+ run's direct children. Neither reported the disagreement, so a parent chasing its own **failed** child
680
+ collected another run's **successful** one and believed the work was done; `continue` would then use the
681
+ wrong node's model and outcome, and `stop` picked the same wrong record and answered
682
+ `No non-retired children to stop.` — masking that the name was ambiguous at all. The four actions now go
683
+ through one resolver (`src/runs/child-scope.ts`): the current run's **direct** children win and add no
684
+ new output; a name that only exists elsewhere is reported with `runId` / `nodeId` / `state` for every
685
+ candidate (`collect`/`stop`/`continue` refuse it and name both levers — `runId`, or the parent node's
686
+ scope — instead of acting on it), `status` still shows it but leads with
687
+ `resolved: <name> is not a direct child of this session — showing the record from run <runId>`, and two
688
+ or more same-named candidates are an ambiguity every action refuses rather than guesses. `subagent`
689
+ gained an optional `runId` to name the run a call is scoped to. The scope now travels all the way to the
690
+ **execution point**, which is where the old resolution leaked back in: `stop` hands the run it resolved the
691
+ name in to the retire step, which reads THAT record instead of re-resolving the name across runs — so the
692
+ child it reports and the child it retires are the same one, and a live job carrying the name in another
693
+ run is refused (with the same candidate list) instead of retired. `collect` only consumes the runtime
694
+ snapshot whose `runId` matches the resolved node, the `status` block's `owner:` line is read from the
695
+ resolved node's own run dir (another run's dead owner marker used to render `owner: dead (pid gone)` — and
696
+ even `state=failed` — under this session's record), and the `resolved:` annotation is emitted only for a
697
+ genuinely foreign (`elsewhere`) record, not for a #270 cross-store hit that is this session's own child. A
698
+ session with no run of its own keeps reaching a foreign/restarted run's persisted record (#209-3), which is
699
+ where `pickChildByName` was still the right answer (#312).
700
+
701
+ - **A bare JSON verdict on the last line is no longer reported as `no machine-readable verdict`.**
702
+ `child-guard` injects “End with machine-readable JSON on its own” and `docs/design.md` says
703
+ `attested` is “解析末尾 verdict JSON”, but `extractVerdict` only knew three JSON candidates:
704
+ fenced blocks, `text.trim()`, and the span from the **first** `{` to the **last** `}`. One brace
705
+ anywhere in the report — an API shape, a code fragment, `{ version, entries }` — turned that span
706
+ into prose-plus-JSON, `JSON.parse` failed, and a perfectly valid verdict on the final line was
707
+ thrown away, so the child was recorded `acceptance: unknown` while a sibling whose verdict was
708
+ byte-identical but wrapped in a fence came back `accepted`. Two candidates are added after the
709
+ existing ones and before giving up: the **last line** when it is itself a JSON object (what the
710
+ injection asks for, and it survives trailing blank lines), and a **string/escape-aware brace
711
+ balance scan backwards from the text's last `}`**, which reaches a verdict that spans lines or is
712
+ followed by a short trailing note. The existing `\n\s+` collapsed-span compat candidate is kept,
713
+ moved last because it rewrites the text. Parsing never throws and a report with no JSON still
714
+ returns `null` (#311).
715
+
716
+ - **A provider that ends a turn with no content no longer costs a child its whole turn.** Some
717
+ upstreams answer HTTP 200 and then terminate the stream in-band; pi maps that to
718
+ `stopReason: "error"` with an empty assistant message (`finish_reason: error`, measured with
719
+ `usage.input === 0` and 2/42 output tokens after 90–175s). Nothing retried it: the SDK's retryable
720
+ classifier misses the message, the provider-level retry wrapper only covers thrown HTTP errors,
721
+ and this package's only transient predicate matches `/connection error/i` — whose hold loop polls
722
+ the session file and never re-prompts, so waiting could not help. `collect()` now recognises the
723
+ *shape* (`isEmptyGeneration`: last turn has an assistant record, `stopReason: "error"`, blank
724
+ text, zero tool calls — never `usage`, never the provider's message text) and, while the child is
725
+ still alive and the parent has not aborted, sends it one `CONTINUE_PROMPT` on the same session and
726
+ re-derives the outcome; the failure is tagged `Execution.errorKind =
727
+ "provider-empty-generation"` either way, the parent-visible reason gains
728
+ `(provider returned an empty generation; retried once)` when the retry also came back empty, and
729
+ the ledger's `node_failed` event carries that `errorKind`. The retry budget is its own counter
730
+ (`ChildRecord.providerRetriesUsed`, limit **1**), deliberately NOT the renewal budget — renewals
731
+ are a user-granted allowance for "no measurable progress" that a plain failure never charges, and
732
+ renewal re-runs a child while this continues the same live one. A continue-prompt that an input
733
+ handler swallowed is nobody's retry: it is not charged to the budget, the original failure stands,
734
+ and a warning names what happened — the retry also never classifies its own turn by looser rules
735
+ than a first turn (a `Connection error` on the retry keeps the child `running` instead of retiring
736
+ it). Assistant records also pass the
737
+ upstream `responseId` through for forensics. Contract:
738
+ `docs/contracts/provider-empty-generation-retry-v1.md` (#316).
739
+
740
+ - **A team can no longer deadlock in `implementing` after a partial failure, and two error codes
741
+ stopped lying.** Covered by the `supersedes` / `reason` entry above; the code-hygiene half is
742
+ separate: resuming a terminal legion child reported `BUDGET_EXCEEDED` although nothing about a
743
+ budget was involved, so a parent could not tell "this child is finished" from "this child ran
744
+ out of budget" — the new `ErrorCodes.CHILD_TERMINAL` says what happened, and `reviveChild`'s
745
+ catch no longer rewrites every failure to `START_FAILED` (a `SubagentError` keeps its own code,
746
+ anything else still gets `START_FAILED`). Codes travel only in `details.error`, never in the
747
+ user-visible text, so no tool output gained a `BUDGET_EXCEEDED` string (#309).
748
+
749
+ - **Host extension discovery is no longer a silent failure, and `/legion doctor` can say whether
750
+ inheritance is wired at all.** `ensureHostExtensions` kept its never-reject / R2-b backoff
751
+ semantics but recorded a failure only as a `failedAt` timestamp: a host whose `pi list` breaks
752
+ looked exactly like a host that ships no package extensions, because `discoveredHostExtensions()
753
+ ?? []` collapses "could not ask" and "nothing installed" into one empty array. A failure now logs
754
+ one `[pi-legion]`-prefixed warning carrying a bounded `pi list` stderr summary (the `failedAt` stamp doubles as
755
+ the already-told bit, so a permanently broken probe warns at most once per process instead of once
756
+ per warm call; with no stderr the reason falls back to the exit code, then to pi's own message,
757
+ and the warning itself can never throw). `/legion doctor`'s
758
+ Extensions section answers the question with
759
+ `discovered host extensions: N (last probe: ok | failed <n>s ago | never)`, backed by the new
760
+ read-only `hostExtensionDiscoveryState()` — doctor never probes on its own, so `never` is honest
761
+ and the line costs nothing. The in-process spawn hint also stopped recommending levers that no
762
+ longer do anything (`inheritExtensions: true` is the default policy since #280,
763
+ `PI_LEGION_CHILD_INHERIT_EXTENSIONS=1` is a no-op, and neither brings back a host extension when
764
+ discovery failed): it names `PI_LEGION_CHILD_EXTENSIONS=<path>` and a static `models.json`
765
+ provider, and appends "inheritance is enabled but host discovery is failed/never probed — the
766
+ child got 0 host extensions" when that is the actual situation — on every spawn-hint branch, so
767
+ the rpc `--no-extensions` hint lost its own `inheritExtensions` clause too (it would have
768
+ contradicted the note right below it). Contract revision:
769
+ `docs/contracts/extension-inheritance-wiring-v1.md` §9 (#317).
770
+
771
+ - **A `collect` that bails after claiming no longer swallows that round's completion notice.**
772
+ The claim-once seam claims a terminal result as it hands the snapshot to the tool call, and
773
+ `collectChild` can still bail afterwards — the parent turn aborts, or persisting the child
774
+ throws. Two things were wrong with the rollback. It deleted the claim unconditionally, so a
775
+ bail on a `(runId, name, round)` a real delivery had already claimed erased the evidence and
776
+ resurrected an already-read notice into the next flush; the seam now hands out *whether this
777
+ call took the claim* with the snapshot (`consumeCollect*` returns `{ snapshot, claimedNow }`),
778
+ and only that one comes back — per call, not per snapshot, because the watch and the tool call
779
+ await the same `job.collectPromise` and a shared record lets the second call overwrite the
780
+ first's answer. And it did not hand the notification route back: the seam sets
781
+ `consumedByTool` before the claim, the default async path watches every child, so the watch
782
+ passed its gate first (`!consumedByTool && !notified` → skip `onTerminal`) and was already
783
+ parked in `retire` when the bail arrived — meaning the repair had to re-arm unconditionally,
784
+ which it now does (clear the flag, `watch` again; the generation bump keeps the old watch from
785
+ releasing the job, `notified` keeps the round single, `deliverBatch`'s claim filter is the
786
+ last net). The old `!job.watching` guard is therefore dead code wherever it would have
787
+ mattered: in the default shape somebody is always watching, and an `async: false` inline
788
+ collect registers no watch at all, so the flag is never unset on the path that needs the
789
+ re-arm. Measured in that production shape: 0 notices before, 1 after. The one case still
790
+ not recovered is a bail that lands only after the watch has already released the child — there
791
+ the result stays readable through a later `collect` and a copy already held in the run outbox
792
+ still arrives, but a copy sitting in the join window goes with the release. Contract revision:
793
+ `docs/contracts/notice-claim-v1.md` (D2, #320).
794
+
795
+ - **A `steer` receipt now reports what the child's session actually answered.** `steer` is a
796
+ queue, not an interrupt: the text is enqueued and only consumed at the child's next turn
797
+ boundary — after the current batch of tool calls finishes, before the next LLM call — so a
798
+ child stuck inside one long-running command never receives it, and no amount of steering can
799
+ break into that tool call. The receipt read "Steered <name>." whatever happened, because the
800
+ disposition the child's session reports back (`handled` vs `queued`) was dropped on the way up
801
+ through the supervisor seam — and `handled` never meant delivered: it is the SDK's
802
+ interception signal, returned *before* the message is queued, so the child never sees it. The
803
+ receipt now has three honest shapes: `queued` (it takes effect after the current tool batch
804
+ finishes), `accepted` (the child was idle, so this call started a turn with it) and
805
+ `intercepted` (an input handler took it — the child never saw it). Resuming or continuing a
806
+ live child carries the same distinction, and the parent playbook plus the v2 contract now
807
+ state the negative semantics — a tool call in flight is unreachable by `steer`; `stop`
808
+ (`force: true` for the hard variant) is what actually interrupts. Because `intercepted` means
809
+ "no run started", the turn it had claimed is rolled back: `waitSettled` answers at once with
810
+ `prompt was intercepted before any run started` instead of timing out, the child does not stay
811
+ `running` forever, and a launch prompt swallowed that way fails the spawn rather than handing
812
+ back a child that was born busy. Legion mail sent to a busy child with `action-needed` now logs
813
+ a warning when its steer was intercepted — `delivered=1` still records the send (D3), so the
814
+ warning is the only signal that the child never saw the message.
815
+
816
+ - **A child's completion notice is delivered once, whichever route won.** A terminal
817
+ result reaches the parent two ways — the tool result (`wait` / `collect`) and the
818
+ pushed notice (`watch` → `join` → outbox) — and each kept its own memory
819
+ (`consumedByTool` / `notified`), judged once when the watch hit terminal. The notice
820
+ still had to cross the 10s join window and the run-scoped outbox, neither of which
821
+ knew those flags existed, so four duplicate windows followed: a buffered entry
822
+ flushed after a `wait` (W1), an outbox entry flushed on `agent_settled` after a
823
+ `wait` (W2), a buffered entry orphaned by `release()` (W3), and the flags
824
+ evaporating with the deleted job (W4).
825
+ - Dedupe collapses onto **one claim per `(runId, name, round)`**. `round` is a
826
+ per-name counter because `runId` is the *parent* run id — every round of a
827
+ same-named child shares it, so a two-part key lets an unread old round win the
828
+ flush and filter out the new round's real result. Three claim sites — `wait`
829
+ (before its retire await), the `collect` seam, and a notice that actually went
830
+ out — and one rollback: when a `collect` claims and then bails (the parent turn
831
+ aborts, or persisting the child fails), the claim is handed back, because a
832
+ notice for a result nobody saw must not stay suppressed. Delivery itself never
833
+ unclaims.
834
+ - `deliverBatch` is the single exit: claimed entries are filtered, survivors
835
+ formatted (grouped headers and the `N of M` count recomputed at delivery time),
836
+ then sent or held. The outbox now holds structure rather than formatted text, so a
837
+ member claimed while a notice waits drops out of the body and the count alike.
838
+ - Each round is one deliverable result, so releasing a child only ever drops the
839
+ copy of the round the parent just read: an earlier round's unread notice still
840
+ arrives.
841
+ - Tool results are **never** suppressed: `wait` / `collect` always return the full
842
+ text, only the passive notice is deduplicated. Claim state stays in memory by
843
+ design — after a process restart the result is still recoverable via `collect`.
844
+ - Contract and the four implementation-time revisions:
845
+ `docs/contracts/notice-claim-v1.md`.
846
+
847
+ - **A timed-out or aborted verify command no longer leaves its grandchildren running.** Both
848
+ command runners (`runVerifyCommand` for acceptance, `runLegionCommandNow` for `legion-verify`)
849
+ killed only the direct child — `bash -lc` / `timeout` — so everything the command had forked
850
+ was reparented to init and kept running: a `node --test` was still alive 44 minutes after its
851
+ run with `ppid=1`. Both now spawn `detached: true` (own process group) and, on the timeout,
852
+ output-cap and abort paths, run the new shared `sweepProcessTree()`
853
+ (`src/shared/proc-sweep.ts`): group signal first, then every descendant enumerated from
854
+ procfs (which covers a `setsid` escapee), then — for as long as the process group still holds a
855
+ live member, the spawned root included — `SIGKILL` to the group and to every pid the grace left
856
+ behind, waited for before the runner resolves. That "still a live member" clause is the point:
857
+ a root that installs a `SIGTERM` handler and never forks used to survive the whole sweep while
858
+ the timeout result reported no survivors. The runner's promise resolves only **after** that
859
+ sweep, so a caller that sees a timeout knows the tree is gone; the price is up to
860
+ `graceMs + killAfterMs` (2.5s) of extra latency on those paths, documented in
861
+ `docs/contracts/proc-sweep-v1.md`. Two exits are therefore *not* timeouts: a **normal** exit is
862
+ never swept (`cmd &` in a verification command is a legal way to leave a background process
863
+ behind — leftovers are reported with one `[legion]` warning naming the command and the pids),
864
+ and neither is a command that already exited while an unredirected `cmd &` kept its pipes open.
865
+ `legion-verify` also stops reading its inner `timeout(1)` as a clean exit: `124` (or death by
866
+ signal) is a timeout, and it is swept too. A sweep that still ends with survivors warns instead
867
+ of staying silent. `/proc` absent (non-Linux) degrades to the process-group signal and never
868
+ throws; `ESRCH`/`EPERM` are normal outcomes — but there the clean-exit leftover warning is dead
869
+ code, because both of its sources are procfs. The `signal` parameter has no production caller
870
+ yet (abort wiring is a todo). (#310)
871
+
14
872
  ## [5.0.1] — 2026-10-27
15
873
 
16
874
  Patch release. **This is the release where 5.0.0's extension-inheritance policy