pi-legion 3.0.1 → 3.1.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.
package/docs/design.md CHANGED
@@ -219,18 +219,48 @@ launch (async) ──→ 子 pane 跑
219
219
  │
220
220
  ├─ TUI:输入框上方 aboveEditor widget(live agent 面板)
221
221
  │ (pi-subagents fleet-status;footer 不再重复计数,见 #192)
222
- └─ collect 在后台等 turn 结束
222
+ └─ collect 在后台等 run 结束
223
223
  │
224
- └─ pi.sendMessage({ customType: "subagent-notify" },
225
- { triggerTurn: true, deliverAs: "followUp" })
226
- 父会话空闲则立刻唤醒;若父会话还在跑当前 turn(工具循环),
227
- 等这一轮结束后再投递,避免 steer 打断正在干的事。
228
- 所有状态都 display:true(transcript 留下完成/失败记录);
229
- 成功由 notice-renderer 压成单行并按 pane 宽度硬截断
230
- (控制字符已消毒,ctrl+o 展开),
231
- 失败/中止保留完整块,details 只供 TUI 不进 LLM 上下文。
224
+ └─ runtime 完成通道(唯一的延迟源,按 **run** 级判定)
232
225
  ```
233
226
 
227
+ 完成通知的投递分两段,**只有一处 run 级延迟**:join 窗口是纯批处理延迟,二者串行叠加
228
+ (最坏串联约 10s + 80s)——除此之外不存在第二个"等父会话忙完"的延迟源。
229
+
230
+ 1. **join 批处理窗口**(`src/extension/join.ts`,默认 10s):同一 runId 的多个 child
231
+ 一起启动、先后结束,窗口把 straggler 合成一条消息(`Background tasks completed
232
+ (N of M)`)。窗口**只做批处理**,到期无条件 flush 到下一个环节,**不**因为父会话忙
233
+ 而延长——turn 级的 busy 是错误预测器(工具循环里父会话按定义就是忙的),而且会在
234
+ outbox 之外叠加第二层延迟。
235
+ 2. **run 级 outbox**(`src/extension/runtime.ts`,`MAX_PENDING_AGE_MS = 80_000`):
236
+ 父 **run** 活跃时不 `pi.sendMessage`,通知入 FIFO 队列;`agent_settled` 到达后
237
+ 一次性 burst 发出(一条通知一条消息),并重绘 UI。硬上限 80s(= 2× 旧的
238
+ `(1+3)×10s`)到点强制发送并计入 `noticeQueueStats().forcedFlushes`,宁可回到旧
239
+ 行为也不无限隐藏。父 run 不活跃时**立即**发送(含 `mode: "each"`、单成员批次、
240
+ `triggerTurn:false` 项)。
241
+
242
+ 上限是 **per-notice** 而不是 per-run:到点判定用 notice 自己的 `enqueuedAt` 算
243
+ (`runtime.ts` 的 `armOutboxTimer` 从 `outbox[0]` 起算,
244
+ `noticeQueueStats().oldestAgeMs` 也报最老一条)。所以父 run 持续 10 分钟时,不是
245
+ 给整个 run 记一个 80s 预算、也不是因为 run 还在跑就无限顺延:队列里最老一条到点就
246
+ 触发(`flushOutbox` 一次 `splice(0)`,连同队列里更年轻的 notice 一起按 FIFO burst
247
+ 发出)。T3d 把「arm 与 oldestAgeMs 都跟最老一条」钉成测试。
248
+
249
+ 为什么不能只发 `followUp`:宿主只在 **live run 内部** drain `agent.followUpQueue`,
250
+ 所以父 run 活跃时发出去的 followUp 会一直躺在队列里,直到用户再发一条消息才出现。
251
+ 这正是「完成通知看起来没来」的根因。发送时形状不变——
252
+ `pi.sendMessage({ customType: "subagent-notify" }, { triggerTurn: true, deliverAs: "followUp" })`
253
+ ——只是**时机**改到 run 结束时。
254
+
255
+ 所有状态都 display:true(transcript 留下完成/失败记录);
256
+ 成功由 notice-renderer 压成单行并按 pane 宽度硬截断
257
+ (控制字符已消毒,ctrl+o 展开),
258
+ 失败/中止保留完整块,details 只供 TUI 不进 LLM 上下文。
259
+
260
+ > print/json 模式:`session_shutdown` → `dispose()` 丢弃未投递的 pending。child 的
261
+ > session 文件仍在,可用 `collect` 从磁盘恢复;但 `finished` 缓存**不**保留
262
+ > (`dispose()` 开头就 `jobs.clear()` / `finished.clear()`,此后才丢弃 outbox)。
263
+
234
264
  父 agent 的正确用法:`subagent` 工具 launch 之后把控制权交回用户,等 completion
235
265
  message,而不是轮询,也不是让孩子来拍自己。
236
266
 
@@ -1,8 +1,8 @@
1
1
  # In-process AgentSession mode (#197)
2
2
 
3
3
  Default backend for children. **In-process by default**, with an automatic `rpc`
4
- fallback when the resolved spawn needs an OS worktree (`worktree: true`, the
5
- writer/centurion default). An explicit
4
+ fallback when the resolved spawn needs an OS worktree (`worktree: true`, which
5
+ only a `centurion` can hold — #214). An explicit
6
6
  `spawnMode: "inprocess"` combined with `worktree: true` is still **refused**
7
7
  with `INVALID_PARAMS` — the fallback only covers the implicit default.
8
8
 
@@ -15,8 +15,10 @@ with `INVALID_PARAMS` — the fallback only covers the implicit default.
15
15
 
16
16
  There is no single global default in the config sense: omitting `spawnMode`
17
17
  means "inprocess if this role can run in-process, otherwise rpc". Roles that
18
- default to `worktree: true` (writer, centurion) therefore resolve to `rpc`
19
- unless they opt out of the worktree.
18
+ resolve to `worktree: true` therefore resolve to `rpc` unless they opt out of
19
+ the worktree — and since #214 that is only `centurion`: every other role is
20
+ downgraded to `worktree: false` with a one-time warning, so it stays
21
+ in-process.
20
22
 
21
23
  **Non-goals:** no OS worktree isolation; no promise of process crash containment; OpenAaaS Go in-process transport is out of scope.
22
24
 
@@ -29,7 +31,7 @@ Per agent (frontmatter → `agentOverrides` → team member override):
29
31
  ---
30
32
  name: scout
31
33
  description: Fast read-only recon
32
- spawnMode: inprocess # omit = inprocess (worktree roles auto-fall back to rpc)
34
+ spawnMode: inprocess # omit = inprocess (only a centurion resolves to a worktree, hence rpc)
33
35
  worktree: false # explicit inprocess + worktree: true is still refused
34
36
  ---
35
37
  ```
@@ -126,6 +128,63 @@ Production `defaultSessionFactory` calls `createAgentSession` with:
126
128
 
127
129
  See `buildInProcessSessionPlan` / `test/unit/inprocess-supervisor.test.ts`.
128
130
 
131
+ ## Observability: live token rate during the first run
132
+
133
+ `spawnChild` returns as soon as the child **accepts** the first prompt, not when
134
+ that turn ends (#227 D1). Acceptance is read off the child transcript — a user
135
+ line for this turn appearing past the size recorded at spawn time (`F7`: the SDK
136
+ appends the user message before it runs the agent) — with
137
+ `DEFAULT_INPROCESS_SPAWN_START_TIMEOUT_MS` (15s, injectable as
138
+ `spawnStartTimeoutMs`) as a bound that is **not** an error: on expiry the handle
139
+ is still returned and `onSpawnDelayed` is called once. The turn itself keeps
140
+ running on a background drain, inside the child's identity scope.
141
+
142
+ The one exception is **rejection before acceptance** (#227 D1a): no model, no
143
+ API key, or a compaction in progress makes the SDK throw before it appends the
144
+ user message, so nothing ever lands in the transcript. In that case
145
+ `spawnChild` still **throws** `SubagentError(..., START_FAILED)` and rolls the
146
+ child back rather than returning a handle. That is deliberate: `Orchestrator`
147
+ only retries the next `fallbackModels` entry when the spawn throws
148
+ (`canFallbackStart`), so returning a handle there would silently disable the
149
+ fallback chain. A turn that was accepted and *then* failed stays a runtime
150
+ failure: the handle is returned, `SettleResult.reason` reads
151
+ `child turn failed: …`, and `onSpawnRefused` reports it.
152
+
153
+ So the *reason* the ledger needs a direct read has changed, but the read is
154
+ still required. Two windows remain uncovered by `statusEntryFromJob`:
155
+
156
+ 1. **Before acceptance** (normally milliseconds, up to the start bound): the
157
+ launch has not yet written `node_launched` / `recordChild`, so no job exists.
158
+ 2. **From acceptance to settle**: the job exists, but its usage columns stay
159
+ empty until the turn ends — `pollUsage` only samples nodes that are already
160
+ `running`, and a spawning node is `starting`.
161
+
162
+ During window 2 the ledger row still carries no live token rate, so the status
163
+ board keeps seeing only the ledger node row. That row must still render a live
164
+ token rate, which needs two facts the ledger does not carry during a run:
165
+
166
+ | Fact | Ledger during a run | Source used instead |
167
+ |---|---|---|
168
+ | `tokensOut` | empty until settle: `pollUsage` skips nodes whose status is not `running`, and a spawning node is `starting` | `nodes.session_file` parsed directly |
169
+ | `generationMs` | never stored (`usage` has no such column) | same session parse |
170
+
171
+ Reading `node.sessionFile` stays correct for the same underlying reason as
172
+ before: the transcript is the only live source while the turn runs. What changed
173
+ is that the child is no longer *invisible* to the orchestrator for the whole of
174
+ its first turn — only its **usage ledger** still lags.
175
+
176
+ `statusEntryFromNode` reads `node.sessionFile` through `progressFromSessionFile`
177
+ for both, falling back to the ledger for nodes with no session file (rebuilt or
178
+ foreign rows). Without this the railway header showed `0 tok/s` for the whole
179
+ in-process lifetime while `rpc` looked correct.
180
+
181
+ `generationMs` itself is the sum of measured model-call lengths
182
+ (`message.durationMs`, or entry-to-entry write times). It must never be derived
183
+ from the nested `message.timestamp`, which marks when generation *started* and
184
+ yields only prompt-to-first-token latency — that mistake pinned the denominator
185
+ near zero, so `tok/s` silently fell back to a wall-clock window and stopped being
186
+ a decode rate.
187
+
129
188
  ## Refusals
130
189
 
131
190
  - `kind !== "pi"` → `INVALID_PARAMS` regardless of `spawnMode`: only `pi` roles
@@ -133,6 +192,9 @@ See `buildInProcessSessionPlan` / `test/unit/inprocess-supervisor.test.ts`.
133
192
  worktree fallback
134
193
  - `worktree: true` + explicit `inprocess` → `INVALID_PARAMS` (an omitted
135
194
  `spawnMode` falls back to `rpc` instead)
195
+ - A non-centurion requesting `worktree: true` is **not** a refusal: it is
196
+ downgraded to `worktree: false` with a one-time warning (#214), so the launch
197
+ proceeds in-process. `worktree` is reserved for `centurion`.
136
198
  - Invalid `spawnMode` string → loud throw at settings / frontmatter parse
137
199
 
138
200
  ## Tests
@@ -208,7 +208,7 @@ description: 百夫长/组长:携带一支团队递归派生的指挥角色。
208
208
  kind: pi
209
209
  allowNestedSubagents: true
210
210
  maxSubagentDepth: 2 # R=本角色子树代数上限(含自身;与所在深度无关;只收紧不放宽):1=叶子禁派生;2=可派一层组员、组员不可再派;3=组员(如孙代 centurion)可再派一层
211
- worktree: true # 子树 worktree:本人 cwd 即队的工作区(§9)
211
+ worktree: true # 子树 worktree:本人 cwd 即队的工作区(§9;该权限经 REV-17 收为 centurion 专属)
212
212
  team: # 可选:默认队(须是 settings.teams 里真实存在的队名);省略 = 全 roster 可选;launch 可覆盖(subagent({team:"fe-premium"}))
213
213
  ---
214
214
  ```
@@ -320,7 +320,9 @@ legion_phase({ to, note?, evidence? }) // 仅 centurion(§7)
320
320
 
321
321
  ## 9. 隔离与交付(子树 worktree、MR 树)
322
322
 
323
- - **per-subtree worktree**:centurion launch 时由**其父**创建(复用现有 `createChildWorktree`:`git worktree add -b pi-subagent/<slug>-<nonce>`,D8 仅 git);centurion 的 cwd 即该 worktree;**组员一律 `worktree:false` 原地干活**。隔离边界从"每个 writer"上移到"每个子树",MR 数量从 O(组员数) 降到 O(子树数)
323
+ - **per-subtree worktree**:centurion launch 时由**其父**创建(复用现有 `createChildWorktree`:`git worktree add -b pi-subagent/<slug>-<nonce>`,D8 仅 git);centurion 的 cwd 即该 worktree;**组员一律 `worktree:false` 原地干活**。隔离边界从"每个 writer"上移到"每个子树",MR 数量从 O(组员数) 降到 O(子树数)。**N 并行隔离 writer** = 根直派 N 个 centurion(各 1 worktree),centurion 内组员 inprocess;不要给每个 worker 开 worktree
324
+ - **worktree 仅 centurion 可用**(REV-17,#214):`worktree: true` 是 centurion 角色的专属权限,不存在其他持有者。判定以角色名 `centurion` 为准:centurion 卡一律拿到子树 worktree(嵌套 centurion 拿 nested worktree,不受「父是 centurion ⇒ 组员不隔离」压制);centurion 的组员一律不拿;其余角色(`worker` / `prototype` / `scout` / `reviewer` / `planner` / `advisor` / `designer`,含用户自建 writer 角色)既不自动获得、也不会因 `acceptance.role: writer` 而获得 worktree
325
+ - **违规处理为降级,不是报错**(REV-17):非 centurion 角色卡声明 `worktree: true`、或 `subagent({ worktree: true })` 显式传参,一律**降级为 `worktree: false`**,并 `console.warn` 一次(每 Orchestrator 实例一次,同 `depthWarningEmitted` 语义)。**不抛错**——抛错会阻断 launch,而降级的语义是「回到契约本来的边界」;被降级的组员与父共享 checkout,其改动留在父的工作区里
324
326
  - 孙代 centurion 在子树 worktree 内再开 nested worktree(git 原生支持),递归成立
325
327
  - **MR 树**(D6):每棵子树一个 squash MR → 父节点(或其队)合并 → 逐级上交 → 根合入默认分支。合并权在各级父节点,叶子永不 merge(现有纪律的递归推广)
326
328
  - 文件所有权清单随契约(§7.2)逐层传递;契约矛盾 = 正确拒绝,上报修订后重派
@@ -414,7 +416,7 @@ interface LegionEventBus {
414
416
 
415
417
  ### M2 军团树(组织层:db + centurion + 状态机 + 邮件)
416
418
 
417
- - **归属**:新增 `src/legion/{db,nodes,events,usage,mail,state-machine,budget,rebuild}.ts`、`agents/centurion.md`、`src/extension/legion-tools.ts`;修改 `orchestrator.ts`(launch 落库/树命名/预算查库/子树 worktree)、`src/runs/worktree.ts`(subtree 模式)、`src/runs/args.ts`(env 增 `PI_LEGION_DB`/`PI_LEGION_NODE_ID`)、`src/agents/agents.ts`、`index.ts`(队级 resume、collect 落库)
419
+ - **归属**:新增 `src/legion/{db,nodes,events,usage,mail,state-machine,budget,rebuild}.ts`、`agents/centurion.md`、`src/extension/legion-tools.ts`;修改 `orchestrator.ts`(launch 落库/树命名/预算查库/子树 worktree——经 REV-17 收为 centurion 专属,非 centurion 请求 warns 一次后降级)、`src/runs/worktree.ts`(subtree 模式)、`src/runs/args.ts`(env 增 `PI_LEGION_DB`/`PI_LEGION_NODE_ID`)、`src/agents/agents.ts`、`index.ts`(队级 resume、collect 落库)
418
420
  - **验收**:(a) 三层树 e2e:CEO→centurion→workers×2 全阶段走通(契约落盘→并行实施→review verdict→verify 实跑→done 收队 + squash MR);(b) rework 循环:review major→回 implementing→复审通过;(c) rework≥2 → failed 上抛;(d) kill centurion 进程 → 孤儿报警 → resume 恢复整队、settled 组员不复活、运行中组员上下文延续;(e) `maxActiveNodes` 超限拒派 + 事件;(f) 邮件:同级/父子/父←子通、跨子树拒发并提示路由、retired 收件人唤醒投递、终态收件人退信;(g) WAL 压测:60 模拟 supervisor 并发写 5 分钟,busy 超时率 <0.1%;(h) `legion-rebuild` 从 run.json+jsonl 重建 nodes/events;(i) 非 pi kind 明确拒绝
419
421
 
420
422
  ### M3 可视化
@@ -459,3 +461,4 @@ interface LegionEventBus {
459
461
  | 2026-10-11 | REV-14 | §10.1 点击双通道降级:OSC 8 超链接仅作视觉锚点(宿主 TUI 截获链接点击去 openBrowser,pi-tui tui-alt-screen.js:1097-1104 + extensions types 无 URL 回调 API——worker-14 读源码实证);浮窗打开 = 组件鼠标事件 + 键盘路径(/legion <name>)。鼠标事件在全屏模式路由到组件(tree-view 自管分页的点击 handler 已支持),regular 模式无鼠标为宿主限制。触发:worker-14 按 §15-2 正确拒绝(WIP 已由合并权人固化 2dff211) |
460
462
  | 2026-10-11 | REV-15 | §6.1 centurion 角色卡的 `team: be-value` 改为可选省略:be-value 是示例值,仓库/用户 settings 均未定义该队,不覆盖时子进程收警告并退化全 roster。team 字段语义 = 可选的默认队绑定(须真实存在),省略即全 roster。触发:reviewer-6 对 PR #13 的范围外观察 |
461
463
  | 2026-10-12 | REV-16 | §3.2 retire 链加固:SIGTERM 超时后升级 SIGKILL(新 option `killTimeoutMs`,默认 1s),并在 retire 时枚举子 pi 的后代进程(/proc 祖先链),按进程组 SIGTERM→SIGKILL 清场(排除 supervisor 自身进程组与子 pi 自身 pid)。触发:2026-10-12 孤儿 `node --test` 进程泄露事故(两个 PPID=1 进程各烧 4h20m CPU,SIGTERM 无效,人工 SIGKILL 收场)。子契约:`docs/retire-hardening-contract.md`(FROZEN-1) |
464
+ | 2026-10-19 | REV-17 | §9 worktree 收为 centurion 专属(权限变更,按 §15-4 走修订):① 判定以角色名 `centurion` 为准,centurion 卡一律拿子树 worktree、嵌套 centurion 拿 nested worktree;② 其余角色一律不拿——删除「`acceptance.role: writer` 自动补 `worktree: true`」的隐式默认,`agents/worker.md` 从 `worktree: true` 改为显式 `worktree: false`(`agents/centurion.md` 的 `true` 与 `agents/prototype.md` 的 `false` 不变);③ 违规一律**降级为 `false` + `console.warn` 一次**,**不抛错**(抛错会阻断 launch);`subagent({ worktree: true })` 显式传参同样降级。不改 `resolveLaunchWorktree` 的优先级链(不变式 I5),不新增事件类型,不改 `spawnMode` 仲裁逻辑。原因:issue #214(worktree 收为 centurion 专属,与 §9 已冻结的「组员一律 `worktree: false`」对齐;实现向契约收敛,隔离边界继续由「每棵子树」承载) |
@@ -0,0 +1,206 @@
1
+ # Spawn-mode visibility + RPC copy cleanup — frozen contract v1
2
+
3
+ Status: frozen for implementation. Owner decisions are D1–D7; do not re-litigate.
4
+
5
+ ## Why
6
+
7
+ `DEFAULT_SPAWN_MODE = "inprocess"` (`src/supervisor/spawn-mode.ts:18`) has shipped,
8
+ but the parent-facing guidance still says every child is an "RPC child", and the
9
+ actual backend (`handle.spawnMode`) is only visible as a substring of a result
10
+ text line. Users cannot tell inprocess from rpc from the UI.
11
+
12
+ Facts established by recon (do not re-verify):
13
+
14
+ - pi never renders tool-result `details` on its own. The only consumer is
15
+ `renderResult` (`tool-execution.js:243`); with no renderer the host falls back
16
+ to `content` text only. Adding a `details` field alone is invisible.
17
+ - `subagent` / `vexillatio` register **no** renderer (`index.ts:708`, `index.ts:973`).
18
+ - `Handle.spawnMode: SpawnMode` is non-optional and is the arbitrated backend
19
+ (`src/shared/types.ts:482-503`, produced at `src/runs/orchestrator.ts:809-818`).
20
+ - `LaunchSession.handles` is `string[]` (`index.ts:1831`) — the mode is dropped there.
21
+ - The `/legion` node modal footer is `metaPlain()` (`src/tui/live-overlay.ts:737-756`)
22
+ rendering `role · model · turn N (Xs) · elapsed · status`.
23
+ - `legion.nodes` has **no** spawn_mode column (`src/legion/db.ts:47-67`).
24
+
25
+ ## D1 — Wording (backend-neutral) for parent guidance
26
+
27
+ Children are **in-process by default**; a separate `pi` process is used only when
28
+ the launch needs a worktree (centurion) or the role pins `spawnMode: rpc`.
29
+
30
+ Replace "RPC child / headless RPC children / started via RPC / RPC launch" with
31
+ backend-neutral phrasing in these exact places:
32
+
33
+ | File:line | Current |
34
+ | --- | --- |
35
+ | `src/extension/playbook.ts:12` | `Child processes are headless RPC children; no pane or tab is created.` |
36
+ | `src/extension/playbook.ts:13` | `when each RPC child finishes` |
37
+ | `src/extension/playbook.ts:15` | `A collect timeout with a live RPC child` |
38
+ | `src/extension/playbook.ts:25` | `children use --no-extensions` (keep only if still true for the rpc path; phrase as `rpc children use --no-extensions`) |
39
+ | `index.ts:1669` | `playbook: … → RPC launch → watch` |
40
+ | `index.ts:1966` | `playbook: … started via RPC` |
41
+ | `index.ts:1801` | `when each RPC child finishes` |
42
+ | `index.ts:1616` | `(live RPC child steered)` |
43
+ | `index.ts:1574` | `RPC process stopped; session kept for resume:` |
44
+ | `src/extension/recycle.ts:10` | `the RPC child is already stopped` |
45
+ | `src/extension/notify.ts:200` | `RPC child retired.` |
46
+ | `src/extension/notify.ts:237` | `(RPC child is not alive)` |
47
+ | `src/extension/notify.ts:315` | `(RPC child retired)` |
48
+
49
+ Use `child` as the noun. Suggested replacement vocabulary (one word list, keep it
50
+ consistent): `child`, `in-process child`, `rpc child` (lowercase, only when the
51
+ rpc backend is meant), `session`, `watch`.
52
+
53
+ ## D2 — Out of scope for D1 (do NOT touch)
54
+
55
+ These are either internal identifiers or the genuine rpc backend; changing them
56
+ breaks failure classification or contracts:
57
+
58
+ - `src/supervisor/rpc-supervisor.ts` reasons (`Pi RPC process exited …`, etc.)
59
+ - `src/extension/auto-doctor.ts:50` regex (matches `Pi RPC process exited`)
60
+ - `src/runs/orchestrator.ts:146` `TRANSIENT_CONNECTION_REASON` value
61
+ - `src/runs/orchestrator.ts:579/782`, `src/runs/hygiene.ts`, `--no-extensions`
62
+ plumbing, `rpc-child-*` temp dir prefixes, `subagents.rpc.*` keys
63
+ - the frozen head line `interrupted (parent turn ended; children still running)`
64
+ - `docs/contracts/**`
65
+
66
+ ## D3 — `details` carries the mode (machine facts)
67
+
68
+ Additive only; never replace an existing field, never emit `isError`.
69
+
70
+ `subagent` launch-family success + interrupted results gain:
71
+
72
+ ```ts
73
+ spawns?: Array<{ name: string; agent: string; spawnMode: "rpc" | "inprocess" }>
74
+ ```
75
+
76
+ - Emitted only when at least one handle resolved (omitted otherwise, matching the
77
+ existing `stillRunning?` "present only when non-empty" convention).
78
+ - Values come from `handle.spawnMode` (`Handle`, never re-derived, never guessed).
79
+ - Sites: `index.ts:1754` (interrupted) and `index.ts:1807` (normal).
80
+ The interrupted path runs after `handles` is cleared, so keep the resolved
81
+ entries in a parallel field on `LaunchSession` (precedent: `timeoutByName`).
82
+
83
+ `vexillatio` gains `spawnMode` on each handle object:
84
+
85
+ ```ts
86
+ handles: Array<{ name: string; agent: string; sessionFile: string; spawnMode?: SpawnMode }>
87
+ ```
88
+
89
+ `spawnMode` is optional so `ChildRecord.spawnMode?: SpawnMode` legacy runs
90
+ (`src/shared/types.ts:365-373`) render as absent, never as `"rpc"`.
91
+ `docs/vexillatio-contract.md:57` freezes top-level details keys only, so this is
92
+ allowed; do not change that doc.
93
+
94
+ ## D4 — TUI renderer for both tools (this is what makes D3 visible)
95
+
96
+ New file `src/extension/spawn-render.ts`:
97
+
98
+ ```ts
99
+ export function spawnModeSummary(details: unknown): string | undefined
100
+ export function renderSpawnResult(
101
+ result: AgentToolResult<unknown>,
102
+ opts: { expanded: boolean; isPartial: boolean },
103
+ theme: Theme,
104
+ ): Component
105
+ ```
106
+
107
+ Behaviour:
108
+
109
+ 1. `spawnModeSummary` reads `details.spawns` and/or `details.handles[].spawnMode`
110
+ defensively (unknown → undefined). Output: `spawn: scout-0=inprocess`
111
+ or `spawn: worker-0=rpc, scout-1=inprocess`. Entries with an unknown mode are
112
+ skipped; if none survive, return `undefined`.
113
+ 2. `renderSpawnResult` builds the text from `content` text blocks only
114
+ (`stripTerminalSequences`), prepends the summary line when defined, then
115
+ collapsed → first 10 lines + `... (N more lines, ctrl+o to expand)`,
116
+ expanded → all lines. Returns `new Text(text, 0, 0)`.
117
+ 3. It must never throw: wrap the details access in optional chaining; a throw is
118
+ silently swallowed by the host and would blank the card.
119
+
120
+ Attach `renderResult: (result, opts, theme) => renderSpawnResult(result, opts, theme)`
121
+ to both `subagent` (`index.ts:973`) and `vexillatio` (`index.ts:708`).
122
+ No `renderCall`, no `renderShell: "self"`.
123
+
124
+ Rationale: `content` is model-facing, `details` is TUI-facing (`docs/design.md:258`).
125
+ The summary line goes through `details` so it does not enter the LLM context.
126
+
127
+ ## D5 — `/legion` node modal footer shows the same value
128
+
129
+ Thread the arbitrated mode to the overlay without a DB migration:
130
+
131
+ 1. `TrackedJobInput` (`src/extension/runtime.ts:68`) gains `spawnMode?: SpawnMode`.
132
+ 2. Launch sites pass `handle.spawnMode` (`index.ts` ~1914 and the follow sites
133
+ ~2709-2790 that already carry the handle/record).
134
+ 3. `TreeNodeSnapshot` (`src/tui/tree-view.ts:22`) gains `spawnMode?: SpawnMode`;
135
+ `snapshotNodes()` (`src/extension/runtime.ts:608`) merges the job's value onto
136
+ the node snapshot (`job.spawnMode`, matched by nodeId/name, no guessing).
137
+ 4. `liveNode()` (`src/extension/legion-commands.ts:298`) maps it through
138
+ `LiveOverlayNode` (`src/tui/live-overlay.ts:25`).
139
+ 5. `metaPlain()` appends `spawn <mode>` **only when known**; legacy/unknown →
140
+ omit the segment entirely (never print `rpc` as a default).
141
+
142
+ Do **not** add a `legion.db` column and do **not** bump `LEGION_DB_USER_VERSION`.
143
+
144
+ ## D6 — Tests (must be updated/added by the same change)
145
+
146
+ Update (currently lock the old wording):
147
+
148
+ - `test/unit/playbook.test.ts:17` `/headless RPC children/`, `:76` `/RPC child is a progress signal/`
149
+ - `test/unit/notify.test.ts:52,163,169,398,410-411`
150
+ - `test/unit/recycle.test.ts:11`
151
+ - `test/unit/runtime.test.ts:1252`
152
+
153
+ Add:
154
+
155
+ - `spawnModeSummary` returns `undefined` for `{}`/unknown/legacy, and the
156
+ documented string for both launch-family and vexillatio details shapes.
157
+ - `renderSpawnResult` collapsed keeps the summary line and caps at 10 lines +
158
+ hint; expanded prints everything.
159
+ - launch details expose `spawns` with the arbitrated value (extend an existing
160
+ harness that already inspects `details`, e.g. `test/unit/teams.test.ts:1320`).
161
+ - `metaPlain` includes `spawn rpc` / `spawn inprocess` when set and omits it when
162
+ unknown (`test/unit/legion-live-overlay.test.ts`).
163
+
164
+ ## Errata (review round 1, accepted)
165
+
166
+ - **D3/D6 (line numbers)**: the D1 table merged two `index.ts` copy sites into
167
+ `playbook.ts` rows. The real sites are `playbook.ts:12/14/15` plus
168
+ `index.ts:1669/1801/1966/1616/1574`. Behaviour unchanged.
169
+ - **D1 (`--no-extensions`)**: the suggested wording `rpc children use
170
+ --no-extensions` was wrong — in-process children run with
171
+ `noExtensions = !childInheritsExtensions(...)` too
172
+ (`src/supervisor/inprocess-supervisor.ts:145`). The sentence must stay
173
+ backend-neutral; do not name `rpc` there.
174
+ - **D4 (never-throw)**: shape checks are not enough. A `details` object with a
175
+ throwing getter still throws, and the host silently swallows it
176
+ (`tool-execution.js:150-160`). `spawnModeSummary` and `renderSpawnResult` must
177
+ both be wrapped in `try/catch`. Covered by a getter/Proxy test.
178
+ - **D4 (line cap)**: the collapsed cap must count rendered lines, so `\r` and
179
+ binary control bytes are stripped **before** splitting — parity with the host
180
+ fallback (`render-utils.js:40`: `sanitizeBinaryOutput` + `.replace(/\r/g,"")`).
181
+ Without it a `\r` progress stream renders 21 lines and never shows the hint.
182
+ - **D4 (dedupe)**: one `name=mode` entry per child, first occurrence wins.
183
+ - **D5 (`treeNodes()`)**: `snapshotNodes()` alone is not enough. `treeNodes()`
184
+ synthesizes a `TreeNodeSnapshot` for a job with identity but no DB row
185
+ (`runtime.ts:1311-1331`), and that literal dropped `spawnMode`, so the modal
186
+ footer lost the segment in the plain-session / pre-DB-row window. Merge in
187
+ `treeNodes()` (shared helper), not only in `snapshotNodes()`.
188
+ - **D5 (width)**: `spawn <mode>` appended last is truncated to `spawn inpr...`
189
+ at the default 80-column layout (`resolveModalLayout(24,80)` → `innerWidth=78`).
190
+ Put the segment before `model` so the role+status survive, and test with a
191
+ realistic model id.
192
+
193
+ ## D8 — Commit ownership
194
+
195
+ Implementation lands as a single commit **created by the parent agent**.
196
+ Sub-agents must not `git commit`, `git push`, or merge. Round 1 violated this
197
+ (`80890ba` was authored by the worker); it is amended by the parent in round 2.
198
+
199
+ ## D7 — Acceptance
200
+
201
+ - `npm run typecheck`
202
+ - `npm test`
203
+ - `npm run test:integration`
204
+ - No new `RPC child` occurrences in parent-visible strings: the D1 table is empty
205
+ afterwards, and `rg -n 'RPC child|headless RPC|via RPC' src index.ts` returns
206
+ only the files listed in D2.
@@ -102,12 +102,16 @@ and override semantics.
102
102
  ## spawnMode (rpc | inprocess)
103
103
 
104
104
  Omitted → `inprocess`, with an automatic `rpc` fallback when the resolved spawn
105
- needs an OS worktree (`worktree: true`, the writer/centurion default). A
106
- non-`pi` role is rejected before this point, so it never reaches the fallback.
107
- An explicit `spawnMode: "inprocess"` + `worktree: true` is
108
- still refused with `INVALID_PARAMS`. Precedence: frontmatter →
105
+ needs an OS worktree (`worktree: true`, which only a `centurion` can hold —
106
+ #214). A non-`pi` role is rejected before this point, so it never reaches the
107
+ fallback. An explicit `spawnMode: "inprocess"` + `worktree: true` is still
108
+ refused with `INVALID_PARAMS`. Precedence: frontmatter →
109
109
  `subagents.agentOverrides.<role>` → team member override.
110
110
 
111
+ `worktree: true` is centurion-only: any other role that asks for it is
112
+ downgraded to `worktree: false` with a one-time warning, so squad members always
113
+ run in-process in the centurion's subtree worktree.
114
+
111
115
  See [inprocess-supervisor.md](./inprocess-supervisor.md) (#197) for the backend
112
116
  details, the separate `subagents.inprocess.*` limits, and the
113
117
  AsyncLocalStorage identity channel.
@@ -54,7 +54,7 @@ const VexillatioParams = Type.Object({
54
54
 
55
55
  1. 解析 agent:默认 `scout`;目标必须在 catalog 里且 `utility === true`,否则报错 `not a utility agent: <name> (mark it with 'utility: true')`。
56
56
  2. 走**既有 `orchestrator.launch`**(复用 budget/session 管线),但 `SpawnInput.env` 显式剔除 legion 身份四变量:`PI_LEGION_DB`、`PI_LEGION_NODE_ID`、`PI_SUBAGENT_PARENT_PATH`、`PI_SUBAGENT_MAX_DEPTH`(防幽灵节点 + 防工具人继承指挥链身份)。**不剔除** `PI_SUBAGENT_CHILD`、`PI_SUBAGENT_CHILD_ROLE`、`PI_SUBAGENT_MAX_TOOL_CALLS` 等预算变量(child-guard 仍要工作)。utility 目标(两通道)**剥除 `PI_SUBAGENT_ALLOW_NESTED`**(§2.5.1,utility 语义优先);`lineageEnv` 统一设置 `PI_SUBAGENT_UTILITY_CHILD`(§2.2)。
57
- 3. launch 后 `awaitSupervisor` + 静默宽限(复用 `collect` 的 grace 逻辑),产出直接以文本返回(`renderCollect` 简化版:`── <name> ──\nexecution: …\n<output>`),同 `collect` 的 `outputFile` 惯例写 `<name>.output.md`。
57
+ 3. launch 后 `awaitSupervisor` + 静默宽限(复用 `collect` 的 grace 逻辑),产出直接以文本返回(`renderCollect` 简化版:`── <name> ──\nexecution: …\n<output>`),同 `collect` 的 `outputFile` 惯例写 `<name>.output.md`。**中断返回语义(REV-17.9)**:父轮结束时(`signal.aborted`)不丢弃已收集的结果——返回文本 = 冻结头行 `interrupted (parent turn ended; children still running)` + 已收集的每一段(顺序 = handles 顺序,含 launch 失败行 `✗ <agent>: <error>`);`details` 带 `{ runId, handles, async: false, interrupted: true, stillRunning? }`。没有 handle 时文本退化为只有头行 + 失败行,不出现空段;`interruptedCollect` 仍给 `status:"running"` + `reason:"aborted by parent"`,照旧渲染、不改写成 success。
58
58
  4. 返回值含 `outputFile`/`sessionFile` 路径,父可后续用 `subagent({action:'collect'|'steer'|'retire', name})` 控制——**节点进 legion 树**, parent = 派发进程自己的 nodeId;无 legion 身份的**child** 派发者走普通 run 目录管线(子记录必须 `store.addChild` 落 run.json,同 launchStep 惯例——REV-17.8 修正:原措辞导致实现只 updateChild 丢记录);**root** 派发者照 subagent 通道惯例创建 root legion(原措辞含糊,见 REV-17.8 修订记录)。
59
59
 
60
60
  ### 2.3 env 语义(冻结表)
@@ -160,3 +160,4 @@ const VexillatioParams = Type.Object({
160
160
  | 2026-10-12 | REV-17.6 | §2.2/§2.3/§2.5.1/§2.5.3 四处统一:**utility 语义优先,allowNested 被忽略**——两通道对 utility 目标都剥除 ALLOW_NESTED,不存在持有 subagent 的工具人,误配不恢复递归路径(§2.5.1 原句“此时持有 subagent”是 17.4 引入的后门,与硬叶子不变量自相矛盾) | worker-5 指出硬叶子规则与误配时持有 subagent 的规定冲突——拒绝正确,修订后重派 |
161
161
  | 2026-10-12 | REV-17.7 | 新增 §2.7 工具白名单注入规则(`pushToolArgs` 对非 utility 角色追加 vexillatio);§3 新增 `src/runs/args.ts` 所有权 + 两个 `read,bash` 断言授权(orchestrator.test:99 / supervisor.test:265)。分支已合入 master 最新(PR #22) | worker-6 完成全部实现后指出:受限角色(显式 tools: 白名单)调不到未列名的 vexillatio,需在 args 层追加且两个既有断言未授权——拒绝正确;从原会话恢复继续,勿重写已完成的 90% |
162
162
  | 2026-10-13 | REV-17.8 | 三路 review 门禁退回后的修复授权:M1=非 legion 派发者子记录持久化(persistChild 改 findChild 守卫式 addChild+updateChild,补集成测试);M3=package.json 四脚本加 `-u PI_SUBAGENT_UTILITY_CHILD`;测试缺口补:跨通道同名续接(subagent 先派 scout-0 后 vexillatio 应派 scout-1)与超时行为(不 autoSettle 场景);契约自身修正:§2.2 步骤 4 措辞(child 走 addChild 惯例/root 走 createRootLegion)、rpc-supervisor/types.ts 追认、§3 新增 package.json。四个 minor 采纳:abort signal 贯通、strip 列表常量化、verify 字段报错、child-mode 越授权改动追认;其余 minor(F5 重构收敛等)记档不阻塞 | 四路 reviewer 独立印证 3 major(M1 探针实证 children:[]、M2 部署惰性、M3 32 假失败实测);采纳 worker-0/2/3 交叉发现 |
163
+ | 2026-10-19 | REV-17.9 | §2.2 步骤 3 补中断返回语义:父轮结束时返回已收集结果 + `details.interrupted`,冻结头行保持逐字(#227 D4) | issue #227 缺陷 2(中断丢弃已收集结果,`index.ts:2416` / `:743-745` 两层丢弃);`docs/contracts/vexillatio-nonblocking-launch-v1.md` 冻结 |