pi-legion 2.0.4 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/CHANGELOG.md +71 -1
  2. package/CONTRIBUTING.md +27 -24
  3. package/README.md +103 -19
  4. package/docs/contracts/osc-busy-plugin.md +72 -0
  5. package/docs/contracts/spawn-default-inprocess-v3.md +322 -0
  6. package/docs/contracts/wait-not-stop.md +30 -0
  7. package/docs/design.md +2 -2
  8. package/docs/headless-child-contract.md +83 -0
  9. package/docs/inprocess-supervisor.md +140 -0
  10. package/docs/qa-hunting-round.md +150 -0
  11. package/docs/subagents-teams.md +55 -18
  12. package/docs/tui-qa-playbook.md +284 -0
  13. package/index.ts +315 -49
  14. package/package.json +15 -8
  15. package/skills/pi-legion/SKILL.md +5 -0
  16. package/src/agents/agents.ts +34 -6
  17. package/src/agents/context.ts +83 -1
  18. package/src/agents/overrides.ts +1 -0
  19. package/src/agents/settings.ts +122 -1
  20. package/src/api.ts +23 -0
  21. package/src/extension/child-guard.ts +42 -3
  22. package/src/extension/legion-commands.ts +9 -2
  23. package/src/extension/legion-doctor.ts +54 -0
  24. package/src/extension/legion-stop.ts +6 -2
  25. package/src/extension/playbook.ts +3 -3
  26. package/src/extension/runtime.ts +57 -8
  27. package/src/extension/scout-guard.ts +39 -1
  28. package/src/extension/session-errors.ts +191 -0
  29. package/src/runs/args.ts +8 -0
  30. package/src/runs/orchestrator.ts +125 -16
  31. package/src/shared/output-cap.ts +2 -2
  32. package/src/shared/session.ts +2 -1
  33. package/src/shared/types.ts +74 -0
  34. package/src/supervisor/child-context.ts +90 -0
  35. package/src/supervisor/child-memory.ts +27 -3
  36. package/src/supervisor/inprocess-depth.ts +71 -0
  37. package/src/supervisor/inprocess-supervisor.ts +1011 -0
  38. package/src/supervisor/routing-supervisor.ts +179 -0
  39. package/src/supervisor/rpc-supervisor.ts +14 -0
  40. package/src/supervisor/spawn-mode.ts +35 -0
  41. package/src/supervisor/types.ts +13 -0
  42. package/src/supervisor/ui-proxy.ts +78 -0
  43. package/src/tui/live-overlay.ts +147 -55
  44. package/src/tui/status.ts +71 -35
  45. package/src/tui/viz/model.ts +6 -0
  46. package/src/tui/viz/panels.ts +8 -9
  47. package/src/tui/viz/railway.ts +154 -21
  48. package/src/tui/viz/rate.ts +15 -14
@@ -0,0 +1,322 @@
1
+ # 冻结契约:spawn 默认翻转为 inprocess(v3.0.0)
2
+
3
+ 状态:**FROZEN** — 实施中禁止偏离。发现矛盾立刻停下报告,不要自圆其说。
4
+
5
+ ## 目标
6
+
7
+ `spawnMode` 缺省值从 `rpc` 改为 `inprocess`,且**不引入任何已知回归**。翻转默认是 breaking
8
+ change,因此本次发 `3.0.0`。
9
+
10
+ ## 已实测的硬事实(禁止重新侦察)
11
+
12
+ | 事实 | 证据 |
13
+ |---|---|
14
+ | 默认常量唯一开关 | `src/supervisor/spawn-mode.ts:9` |
15
+ | 写角色卡写死 `worktree: true` | `agents/centurion.md:8`、`agents/worker.md:22`;`agents.ts:563-566` 对 `acceptance.role: writer` 自动补 |
16
+ | orchestrator 的 worktree 解析在 spawnMode 检查**之后** | `orchestrator.ts:549`(`inProcess`) vs `:575-583`(`worktree`) |
17
+ | `:550` 查**原始**字段,绕过 `worktree: false` 覆盖 | `orchestrator.ts:550`;优先级见 `src/runs/worktree.ts:34-45` |
18
+ | `:550`/`:584` 零测试覆盖 | grep `cannot combine with worktree isolation` 在 `test/` 无命中 |
19
+ | inprocess 无配置块 → `limits() === {}`(无上限) | `inprocess-supervisor.ts:322-331`,注释原文 `// No inprocess block → no limits (unlimited).` |
20
+ | in-process 子会话与父共享 `process.env`(值为**父的**,不是空) | 实测;`createAgentSession` 的 options 里 `env` 字段数 = 0 |
21
+ | `SessionManager.inMemory()` → `getSessionFile() === undefined` | `session-manager.js:1364-1365`;实测 |
22
+ | `SessionManager.open(空文件)` 成功且 `getSessionFile()` 正确 | 实测 |
23
+ | `globalThis[Symbol.for(k)]` 持有 ALS 经 jiti `moduleCache:false` 仍同一实例 | 实测:`childMod.store === shared → true`,父 ALS 值可被子模块读到 |
24
+ | `bindExtensions` 仅 rpc/print/interactive 模式调用,SDK session 不触发 `session_start` | `agent-session.js:2612-2619`;三个 mode 调用点 |
25
+
26
+ ## 不变量(必须保持)
27
+
28
+ - **I1**:显式 `spawnMode: "inprocess"` + 解析后 `worktree === true` → 仍然 **loud throw** `INVALID_PARAMS`。仲裁只影响「缺省」。
29
+ - **I2**:`kind !== "pi"` + inprocess → 仍然 loud throw(`routing-supervisor.ts:73-78`、`inprocess-supervisor.ts:336-341`)。
30
+ - **I3**:RPC 路径行为**零变化**。`lineageEnv()`、`buildPiArgs`、`rpc-supervisor` 的 env 遮蔽逻辑不动。
31
+ - **I4**:`test/unit/inprocess-supervisor.test.ts` 现有 10 条必须全绿(尤其 `:172-186` maxActive 默认断言、`:188-228` 路由/跨后端同名校验)。
32
+ - **I5**:`test/unit/worktree.test.ts:52-58` 的 `resolveLaunchWorktree` 优先级不动。
33
+ - **I6**:禁止在 `spawnMode` 显式给值时做任何静默降级。
34
+
35
+ ## 决策
36
+
37
+ ### D1 — 默认常量
38
+ `DEFAULT_SPAWN_MODE: SpawnMode = "inprocess"`(`src/supervisor/spawn-mode.ts:9`)。
39
+ `resolveSpawnMode` 保持纯配置解析语义:**只返回意图**,不感知 worktree。
40
+
41
+ ### D2 — 仲裁位置与形状(唯一实现点:`Orchestrator.launch`)
42
+ 把 `const inProcess = isInProcessSpawn(input.agent)` 从 `:549` **移到 `:583` 之后**(`worktree`
43
+ const 结束处)。`:549` 与 `:583` 之间无 `inProcess` 使用点(仅 `:550`、`:564`、`:584`),移动是局部的。
44
+
45
+ 仲裁逻辑(紧接移动后的位置):
46
+
47
+ ```ts
48
+ const explicitMode = input.agent.spawnMode !== undefined;
49
+ // 缺省 + 解析后需要 worktree / 非 pi → 回落 rpc。显式给值绝不降级。
50
+ const mode: SpawnMode =
51
+ !explicitMode && worktree === true
52
+ ? "rpc"
53
+ : resolveSpawnMode(input.agent);
54
+ const inProcess = mode === "inprocess";
55
+ // 下游必须看到仲裁结果,否则 RoutingSupervisor 会独立重算并抛错。
56
+ // `worktree` 也必须归一化:角色卡上的 worktree:true 在本次发射「不需要隔离」时
57
+ // 会让 RoutingSupervisor(:79) / InProcessSupervisor(:342) 直接拒绝,即使 mode 已是
58
+ // inprocess 且解析结果 worktree===false。只改 spawnMode 不改 worktree 是错的。
59
+ const agent: AgentConfig = mode === input.agent.spawnMode && worktree
60
+ ? input.agent
61
+ : { ...input.agent, spawnMode: mode, ...(worktree ? {} : { worktree: false }) };
62
+ ```
63
+
64
+ **硬约束(两条,缺一不可)**:
65
+ 1. 仲裁结果必须**写回一个 agent 克隆**并同时传给 `reserveNode({agent})`(`:636`)和
66
+ `spawnInput.agent`(`:652`)。否则 `routing-supervisor.ts:71` 会独立重算
67
+ `resolveSpawnMode` 得到 `inprocess` 并在 `:79-84` 抛错,仲裁白做。
68
+ 2. 克隆必须**同时归一化 `worktree`** 为本次发射的真实值(不需要隔离时写 `false`)。
69
+ 只写 `spawnMode` 会让 `{spawnMode:"inprocess", worktree:true}` 这个自相矛盾的组合
70
+ 流到 `routing-supervisor.ts:79` 与 `inprocess-supervisor.ts:342`,直接 `INVALID_PARAMS`。
71
+ 这正是 `centurion 派 worker` 主链路的形态(centurion 父 → `parentRole==="centurion"` →
72
+ 解析 worktree=false,而 `agents/worker.md` 写死 `worktree: true`),必须由该归一化兜住。
73
+
74
+ **注(`kind !== "pi"` 子句)**:`Orchestrator.launch` 在 `:545-548` 已对 `kind !== "pi"` 早抛,
75
+ 因此仲裁里不再重复该条件(原契约写的 `|| input.agent.kind !== "pi"` 在本文件内不可达,属死代码)。
76
+ `kind !== "pi"` 的保护由 `:545-548` 早 guard 承担,不在本契约范围内变更其归属。
77
+
78
+ ### D3 — 删除 `:550` 的原始字段门,统一到 `:584`
79
+ `orchestrator.ts:550-555`(`if (inProcess && (input.worktree === true || input.agent.worktree === true)) throw`)
80
+ **删除**。保留并复用 `:584` 的检查,改为基于仲裁结果:
81
+
82
+ ```ts
83
+ if (inProcess && worktree) { throw ... } // 只可能命中"显式 inprocess"
84
+ ```
85
+
86
+ 理由:D2 已保证「缺省 + worktree → rpc」,所以此处命中必为显式,I1 不受影响;同时修掉
87
+ `regressions.test.ts:148` 的 bug(调用方显式 `worktree: false` 被原始角色字段误拒)。
88
+ `:564` 的 `if (!input.utilityDispatch && !inProcess)` 保持原样(移到新位置后语义不变)。
89
+
90
+ ### D4 — inprocess 安全默认限额(契约变更,必须同步改文档)
91
+ `InProcessSupervisor.limits()`(`inprocess-supervisor.ts:322-331`)改为:
92
+
93
+ ```ts
94
+ private limits(): Required<Pick<InProcessLimits,"maxDepth"|"maxChildrenPerNode"|"maxActive">> & InProcessLimits
95
+ ```
96
+
97
+ - **无 `subagents.inprocess` 块** → `{ maxDepth: 4, maxChildrenPerNode: 8, maxActive: 30 }`
98
+ (镜像 `legion.*` 默认值:`types.ts:539-542`,`budget.ts:6-9`)。
99
+ - **有块但缺 `maxActive`** → 维持现状 `DEFAULT_INPROCESS_MAX_ACTIVE = 8`(I4 依赖这条,不得改)。
100
+ - 有块且显式给值 → 原值。
101
+ - `maxSpawnsPerSession` / `waitTimeoutMs` **不给默认**(与 RPC 列对称:RPC 侧无默认)。
102
+
103
+ 理由:默认翻转后「无配置 = 无限递归派生」不可接受。这是**已记录**的契约变更,必须改
104
+ `docs/inprocess-supervisor.md:45-55` 与 `src/shared/types.ts:599-611` 的注释。
105
+
106
+ ### D5 — `SpawnInput.env` 不被 inprocess 消费:以进程内上下文取代 env
107
+
108
+ **新增文件 `src/supervisor/child-context.ts`**(API 冻结,实现禁止改签名):
109
+
110
+ ```ts
111
+ import type { SpawnMode } from "../shared/types.ts";
112
+
113
+ /** 一个 in-process 子会话的血缘身份。绝不经过 process.env。 */
114
+ export interface ChildLaunchContext {
115
+ /** 本子会话的 legion node id(根级 in-process 子会话为 undefined) */
116
+ nodeId?: string;
117
+ /** 父 node id */
118
+ parentNodeId?: string;
119
+ /** legion.db 绝对路径 */
120
+ dbPath?: string;
121
+ /**
122
+ * Nested path segments — value equals the RPC `PI_SUBAGENT_PARENT_PATH`
123
+ * (encodeNestedPath(childPath(name))), i.e. it ALREADY includes this child.
124
+ * Currently unread; a future consumer must pass it straight to
125
+ * Orchestrator's `deps.parentPath`, never append to it.
126
+ */
127
+ parentPath?: NestedPathEntry[];
128
+ /** role 的 acceptance.role(read-only / writer / …) */
129
+ acceptanceRole?: string;
130
+ /** 是否允许该子会话再派子(对应 allowNestedSubagents) */
131
+ allowNested: boolean;
132
+ /** utility 硬叶子 */
133
+ utility: boolean;
134
+ /** 该子会话的 spawnMode(供子会话的 doctor/诊断自陈) */
135
+ spawnMode: SpawnMode;
136
+ }
137
+
138
+ /** 在给定上下文下执行 fn;上下文经 globalThis+Symbol.for 持有,可跨 jiti 模块实例。 */
139
+ export function runInChildContext<T>(ctx: ChildLaunchContext, fn: () => Promise<T>): Promise<T>;
140
+ /** 当前异步上下文里的子会话身份;根进程/根会话返回 undefined。 */
141
+ export function currentChildContext(): ChildLaunchContext | undefined;
142
+ ```
143
+
144
+ 实现要求(已实测可行,不得改用手法):
145
+
146
+ ```ts
147
+ const KEY = Symbol.for("pi-legion.child-context.als");
148
+ const store: AsyncLocalStorage<ChildLaunchContext> =
149
+ (globalThis as any)[KEY] ??= new AsyncLocalStorage();
150
+ ```
151
+
152
+ **禁止**模块级 `const store = new AsyncLocalStorage()`(jiti `moduleCache:false` 会重新求值,
153
+ store 归零 —— 这是 `inprocess-depth.ts:8` 当前的 bug)。
154
+
155
+ `SpawnInput` 新增可选字段 `childContext?: ChildLaunchContext`(`src/supervisor/types.ts`)。
156
+ `InProcessSupervisor.spawnChild` 用 `runInChildContext(input.childContext, ...)` 包裹
157
+ **session factory + 首次 prompt**;`prompt`/`steer`/`followUp`/`abort` 同样在
158
+ `runInChildContext` 内派发(tool 执行发生在这些 await 链里,从而自动继承上下文)。
159
+
160
+ ### D6 — `inprocess-depth.ts` 迁移到 globalThis 存储
161
+ 公开 API 不变(`inProcessDepth()` / `runAtInProcessChildDepthAsync()`),内部 store 改为
162
+ `globalThis[Symbol.for("pi-legion.inprocess-depth.als")] ??= new AsyncLocalStorage<number>()`。
163
+ 理由同上:当前模块级 store 在子模块实例里归零,导致 inprocess 深度上限每次重启。
164
+
165
+ ### D7 — transcript 真实落盘(修「成功但零输出」)
166
+ `defaultSessionFactory`(`inprocess-supervisor.ts:238-298`)把
167
+ `sessionManager: SessionManager.inMemory()` 改为
168
+ `sessionManager: SessionManager.open(input.sessionFile, undefined, plan.cwd)`。
169
+
170
+ - 已实测:`open()` 对 `preCreateSessionFile` 建的空文件成功,`getSessionFile()` 返回该路径。
171
+ - 这样 `orchestrator.ts:899/1171` 的 `deriveOutcome(parseSessionFile(child.sessionFile))`
172
+ 能读到真实消息 → output/verdict/acceptance 恢复。
173
+ - `input.sessionFile` 已在 `InProcessSessionFactoryInput`(`:59`),无需改签名。
174
+
175
+ ### D8 — child-guard 与身份消费端改读上下文(env 仅作 RPC 回落)
176
+
177
+ `src/extension/child-guard.ts`:
178
+ - 新增 `childGuardEnvFromContext()`:从 `currentChildContext()` 取 `acceptanceRole`。
179
+ - `childGuardEnvFromProcess()` 保持存在(RPC 路径用),但调用点改为
180
+ `context 优先,env 回落`:`{ ...childGuardEnvFromProcess(), ...childGuardEnvFromContext() }`。
181
+ 实际实现即:先取 ctx,ctx 有 acceptanceRole 就用 ctx,否则用 env。
182
+
183
+ `index.ts`:
184
+ - `const isChild = process.env.PI_SUBAGENT_CHILD === "1" || currentChildContext() !== undefined;`
185
+ - `const allowNested = process.env[ALLOW_NESTED_ENV] === "1" || currentChildContext()?.allowNested === true;`
186
+ - `resolveLegionIdentity` 新增显式入参 `legionNode?: { dbPath: string; nodeId: string }`;有它时
187
+ **优先于** `process.env[LEGION_DB_ENV]/[LEGION_NODE_ID_ENV]`,且 `sessionFile` 取
188
+ `ctx.sessionManager?.getSessionFile()`(D7 保证非空)。in-process 子会话的 `session_start`
189
+ 不会自动触发,故在 `pi.on("session_start")` 之外,对 `currentChildContext()?.nodeId` 存在的
190
+ 情况**主动调用**一次 `initializeLegionRuntime({ cwd, sessionFile, legionNode })`。
191
+
192
+ **关键安全性质(写进注释)**:ALS 上下文不跨 `spawn()` 边界,因此 `bash npm test` 之类真实
193
+ 子进程**无法**继承身份 —— 这正是 env 通道(`index.ts:375-381` healthy ghost nodes 事故)缺的
194
+ 隔离性。禁回退到 env 注入。
195
+
196
+ ### D9 — 版本与文档
197
+ - `package.json` version → `3.0.0`。
198
+ - `CHANGELOG.md`:`## [Unreleased]` 下新增 `### Changed` 条目说明默认后端变更(breaking),
199
+ 并按既有格式补 `[3.0.0]` 比较链接。
200
+ - `docs/inprocess-supervisor.md`:改标题下的默认说明、`:9` 表格、`:23` 注释、`:45-55` 限额
201
+ 契约(D4)、新增「身份通道 = 进程内上下文」小节。
202
+ - `src/shared/types.ts:49`、`:158`、`:599-611` 注释同步。
203
+ - `README.md` 补 `spawnMode` 与默认值(当前全文 0 次出现,属文档缺口)。
204
+ - `docs/headless-child-contract.md:13` 的 "rpc only" 措辞需限定为「RPC 后端下 rpc only」,
205
+ 注意 `test/unit/headless-child-contract.test.ts:15` 用 `/rpc only/i` 锁字符串 —— 保持该
206
+ 正则仍能匹配。
207
+
208
+ ## 测试要求(新增,缺一不可)
209
+
210
+ | 测试 | 断言 |
211
+ |---|---|
212
+ | `spawn-mode` 缺省仲裁 | 缺省 + `worktree:true` → rpc;缺省 + `worktree:false` → inprocess |
213
+ | **克隆自洽(critical 回归)** | 用**真实** `RoutingSupervisor`(非 FakeSupervisor):centurion 父 + `worktree:true` 角色 + 缺省 mode → 必须**不被 guard 拒绝**(越过 `routing-supervisor.ts:79`);`launch({worktree:false})` 覆盖 `worktree:true` 角色 → 同上 |
214
+ | 显式保护(I1) | 显式 `spawnMode:"inprocess"` + 解析后 `worktree:true` → throw `INVALID_PARAMS`,文案 `/cannot use worktree isolation/` |
215
+ | 显式覆盖(原 bug) | `agent({worktree:true})` + `launch({worktree:false})` → 成功且 `worktreePath === undefined`(**必须用真实 router**,FakeSupervisor 不校验此组合) |
216
+ | inprocess 默认限额 | 无 `inprocess` 块时深度 5 被拒且文案含 `nesting limit` |
217
+ | 身份隔离 | `runInChildContext` 内 `currentChildContext()` 有值;外部为 `undefined`;嵌套两层各自正确 |
218
+ | guard 回落 | ctx 有 `acceptanceRole: "read-only"` 时 `rm -rf` 被拒;无 ctx 时走 env |
219
+ | transcript 落盘 | in-process 子会话结束后 `parseSessionFile(sessionFile)` 有消息(`deriveOutcome.status !== "unknown"`) |
220
+
221
+ **测试真实性硬要求**:凡断言 `spawnMode × worktree` 组合的行,必须用真实 `RoutingSupervisor`
222
+ 或在测试内复现同源判据。`test/helpers/fake-supervisor.ts` **不校验**该组合(已确认),用它断言
223
+ 这组行为等于假绿。
224
+
225
+ ## 非目标(明确不做)
226
+
227
+ - 不做 RPC 侧的 in-process bwrap/sandbox 隔离。
228
+ - 不实现 inprocess 的 OSC `emitBusy`(保持 stub)。
229
+ - 不把 `lineageEnv` 改写为上下文(RPC 也要用 env)。
230
+ - 不改 `MAX_NESTED_PATH_ENTRIES = 4`。
231
+ - 不动 verify-queue / retire 硬化逻辑。
232
+
233
+ ## 文件所有权
234
+
235
+ | Worker | 独占文件 |
236
+ |---|---|
237
+ | W1 | `src/supervisor/spawn-mode.ts`(**仅** D1 常量)、`src/runs/orchestrator.ts`(D2/D3)、`test/unit/spawn-mode-settings.test.ts`(翻转后默认断言)、`test/unit/legion-integration.test.ts` + `test/integration/legion-tree.test.ts` + `test/integration/nesting.test.ts`(FIX=TEST:显式给 `spawnMode: "rpc"` 保住原语义)、`test/unit/spawn-mode-arbitration.test.ts`(新) |
238
+ | W2 | `src/supervisor/child-context.ts`(新)、`src/supervisor/inprocess-depth.ts`、`src/supervisor/inprocess-supervisor.ts`、`src/supervisor/routing-supervisor.ts`、`src/supervisor/types.ts`、`test/unit/inprocess-supervisor.test.ts`(扩展,I4 必须保持全绿)、`test/unit/child-context.test.ts`(新) |
239
+ | W3 | `src/extension/child-guard.ts`、`index.ts`、`src/shared/types.ts`(仅注释 D9)、`README.md`、`CHANGELOG.md`、`package.json`、`docs/inprocess-supervisor.md`、`docs/headless-child-contract.md`、`docs/subagents-teams.md` |
240
+
241
+ **共享但禁止任何 worker 改**:`test/helpers/*`、`src/runs/args.ts`、`src/runs/worktree.ts`、
242
+ `src/legion/*`、`src/supervisor/rpc-supervisor.ts`(I3:RPC 路径零变化)。
243
+
244
+ **集成顺序**:W1 → W2 → W3(W3 的 `index.ts` 本应引用 W2 导出的 `currentChildContext`,必须排最后)。
245
+
246
+ ---
247
+
248
+ ## REV 2 — review 后修订(2026-10-18)
249
+
250
+ 首轮 review 判 `ok:false`(W1 2 critical、W2 1 critical + 6 major)。以下为契约修订,
251
+ **修订原因**:原契约在 D4/D5/D7 三处不足以保证其自身目标。rev2 条目优先级高于上文冲突处。
252
+
253
+ ### REV2-1(D4 补漏)深度必须在「根链 prompt」路径上继承
254
+ `inProcessDepth()` 只在 `spawnChild` 的 `runAtInProcessChildDepthAsync` 内非零。生产里
255
+ orchestrator 对 turn 2+ 是从**根链**调 `supervisor.prompt(name, ...)`,此时深度恒为 0 →
256
+ `assertWithinLimits` 的 `nextDepth` 永远是 1 → `maxDepth:4` 永不触发 → **无限递归**。
257
+ 「spawn → collect → 再 spawn」是 centurion 的常规循环,这是主路径不是边角。
258
+
259
+ **要求**:`inprocess-depth.ts` 新增并导出
260
+ ```ts
261
+ export function runAtAbsoluteDepth<T>(depth: number, fn: () => Promise<T>): Promise<T>;
262
+ ```
263
+ `dispatch` / `abort` / `waitSettled` 三处派发必须用**记录的绝对深度** `child.spawnDepth`
264
+ 压回作用域(而不是 `inProcessDepth() + 1`):
265
+ ```ts
266
+ runAtAbsoluteDepth(child.spawnDepth, () => runInChildContext(child.context, () => ...))
267
+ ```
268
+ `ChildRecord.spawnDepth`(`:197` 声明 / `:370` 唯一写入)当前是只写死字段,正是为此预留。
269
+
270
+ **测试**:必须覆盖「turn1 由 spawnChild 派发、turn2 由根链 prompt 派发、子在自己的 turn 里
271
+ 再 spawn」时序,断言连续第 5 代被拒。只测首轮递归的用例不足以证明 D4。
272
+
273
+ ### REV2-2(D4 第四分支)`inprocess: {}` 与部分块的语义
274
+ `parseInProcessLimits("{}")` 返回 `{}`(不是 `undefined`),会走「有块」分支。
275
+ **裁定(安全优先)**:有块但缺 `maxDepth`/`maxChildrenPerNode` 时,**同样**兜安全默认 4/8;
276
+ 缺 `maxActive` 时兜 8。即三种缺省一律安全默认,禁止「部分块 = 部分无限」。
277
+ 这是**已记录的契约变更**,`docs/inprocess-supervisor.md:55` 与 `src/shared/types.ts:599-611`
278
+ 的 "Omitted fields are unlimited" 必须同步改写。
279
+
280
+ ### REV2-3(D5 快照)`ChildRecord.context` 必须是深拷贝快照
281
+ 禁止把调用方传入的对象(含 `parentPath` 数组)以活引用存进 `ChildRecord` —— 调用方复用模板对象
282
+ 会让**已 spawn 子会话的后续派发身份**跟着变(A 的身份变成 B 的),正是 D5 要消灭的串身份。
283
+ **要求**:元素级深拷贝(`parentPath` 是对象数组,`[...path]` 仍会共享每个
284
+ `NestedPathEntry`):`{ ...provided, ...(provided.parentPath ? { parentPath: provided.parentPath.map((e) => ({ ...e })) } : {}) }`,
285
+ 并补一条「spawn 后修改调用方模板不影响已 spawn 子会话」的测试。
286
+
287
+ ### REV2-4(D5 判别性测试)globalThis 存储必须有能证伪模块级实现的测试
288
+ `AsyncLocalStorage` 天然按 async chain 隔离,所以「并发两路互不串值」对
289
+ 「globalThis vs 模块级」**零判别力**(用被禁止的模块级实现跑同一段逻辑输出完全一致)。
290
+ **要求**:用 `?reload=` 二次加载模块做行为断言 —— A 实例 `runInChildContext` 写入的值,
291
+ B 实例 `currentChildContext()` 必须读到。这才是 D5 的真实需求。
292
+
293
+ ### REV2-5(D7 判别性测试)必须能区分 `inMemory` 与 `open`
294
+ 现有用例只断言 `captured[0].sessionFile === input.sessionFile`,但 **master 的 `spawnChild`
295
+ 本来就传这个字段**(`git show master:src/supervisor/inprocess-supervisor.ts` :360),把实现
296
+ 回滚成 `inMemory()` 这条照样绿。
297
+ **要求**:新增集成级测试 —— 预创建 0 字节文件 → spawn → 子会话跑完一轮 →
298
+ `parseSessionFile(file)` 有消息且 `deriveOutcome(parsed).status !== "unknown"`。
299
+ (已验证可行:`open` 空文件当场补写 header,追加消息后 3 行全部可解析,`turns=1`。)
300
+
301
+ ### REV2-6(stats 形状)真实 `AgentSession` 用量恒为 0
302
+ `stats()` 只读扁平键 `raw.tokensIn`,但真实 SDK 形状是 `tokens: {input, output}` / `cost`。
303
+ 生产 `defaultSessionFactory` 返回真实 session → 用量恒 0,且 `orchestrator.ts:465-470`
304
+ 先在 transcript 写入正确 usage 再被 `stats()` 覆盖为 0。
305
+ **要求**:
306
+ ```ts
307
+ tokensIn: Number(raw.tokensIn ?? raw.tokens?.input ?? 0),
308
+ tokensOut: Number(raw.tokensOut ?? raw.tokens?.output ?? 0),
309
+ costUsd: Number(raw.costUsd ?? raw.totalCost ?? raw.cost ?? 0),
310
+ ```
311
+
312
+ ### REV2-7(注释与导出)
313
+ - `inprocess-supervisor.ts:4` / `:182` 两处注释仍写 `SessionManager.inMemory`,与 `:319` 相反 → 改。
314
+ - `ChildLaunchContext` / `InProcessSessionStats` 未从 `src/api.ts` 导出,但 `SpawnInput` 已公开新增该字段 → 补导出。
315
+ - `childLaunchContext` 未在 `src/supervisor/rpc-supervisor.ts` 的 build 输入 denylist 剔除 → 加进 denylist(身份不该流进 RPC 参数构建器)。
316
+ - `publishEvent` 同步调用父侧 `onEvent` 监听器时会带上子会话 ALS 身份 → 在根上下文里调用监听器。
317
+
318
+ ### REV2-8(测试环境,父会话负责)
319
+ `package.json` 的 5 条 test 脚本未 unset `PI_SUBAGENT_ROLE` / `PI_SUBAGENT_ACCEPTANCE_ROLE`,
320
+ 而 `scout-guard.ts:195` 在该变量存在时直接 return → **任何子 agent 里跑 `pnpm test` 都会
321
+ 误报 8 条失败**(7 条 scout-guard + 1 条 agents-listing)。这污染了全部 worker 的 verify 门禁。
322
+ **由父会话在合并阶段统一修**,worker 不要动 `package.json`。
@@ -0,0 +1,30 @@
1
+ # Contract: wait ≠ stop (parent decides)
2
+
3
+ Status: FROZEN-1 (2026-10-16, #179 Archer lock)
4
+
5
+ ## Rule
6
+
7
+ `wait` (and a still-running / timeout tool result) **never** retires children.
8
+ Esc also does not retire children (`esc-no-retire-v3`). Only an explicit
9
+ `subagent({ action: "stop", … })`, `/legion stop`, or session shutdown retires.
10
+
11
+ When `wait` times out (or otherwise returns while children are live), the tool
12
+ result includes structured fields for the **parent** to decide:
13
+
14
+ - `status`: `wait_timeout` | `still_running` | `budget_exceeded` (budget optional)
15
+ - `done` / `stillRunning[{ name, elapsedMs, budgetMs? }]`
16
+ - `decisions`: `extend` | `steer_again` | `stop` | `force_stop` (model hint enum, not a new RPC)
17
+ - `hint`: wait does not stop children; choose stop/force_stop or extend/steer
18
+
19
+ The parent then calls existing tools (`wait` again, `steer`, or `stop` with
20
+ optional `force: true`). Tooling must not auto-SIGKILL on timeout.
21
+
22
+ ## Force stop
23
+
24
+ `subagent({ action: "stop", name?, all?, force?: boolean })`:
25
+
26
+ - `force` omitted / false → graceful retire (stdin close → SIGTERM escalate)
27
+ - `force: true` → retire hardening (`docs/retire-hardening-contract.md`):
28
+ SIGTERM → SIGKILL + descendant sweep
29
+
30
+ Do not add a parallel `action: "force_stop"`.
package/docs/design.md CHANGED
@@ -217,8 +217,8 @@ herdr 写权限对准父 pane,打断父会话,也依赖父 agent 名叫 `orc
217
217
  ```text
218
218
  launch (async) ──→ 子 pane 跑
219
219
  │
220
- ├─ TUI:输入框下方 widget + footer `N agents running`
221
- │ (pi-subagents fleet-status / setStatus)
220
+ ├─ TUI:输入框上方 aboveEditor widget(live agent 面板)
221
+ │ (pi-subagents fleet-status;footer 不再重复计数,见 #192)
222
222
  └─ collect 在后台等 turn 结束
223
223
  │
224
224
  └─ pi.sendMessage({ customType: "subagent-notify" },
@@ -0,0 +1,83 @@
1
+ # Headless child contract
2
+
3
+ This document pins the trust / approve surface and the exit → settle mapping of
4
+ the **`rpc` backend** so operators and agents do not confuse upstream pi
5
+ behaviour with legion's own `onBlocked` policy.
6
+
7
+ On the `rpc` backend, legion children are **headless Pi RPC processes**. The
8
+ default backend is `inprocess` (an `AgentSession` inside the parent Node
9
+ process), which has no child process, no argv and therefore no trust/approve
10
+ surface — see [inprocess-supervisor.md](./inprocess-supervisor.md).
11
+
12
+ ## Modes (upstream pi) vs legion
13
+
14
+ | Upstream headless form | Interactive tool-approval UI? | Legion children |
15
+ | --- | --- | --- |
16
+ | `-p` / `--print` | No | Not used |
17
+ | `--mode json` | No | Not used |
18
+ | `--mode rpc` | No | **Yes — rpc only** |
19
+
20
+ On the `rpc` backend legion always launches children via `RpcClient` →
21
+ `pi --mode rpc`. Print and json modes are listed only for contrast; they are
22
+ never legion child transports.
23
+
24
+ ## Project trust under headless
25
+
26
+ Headless modes do not show a trust prompt. When `~/.pi/agent/trust.json` has no
27
+ applicable decision for the child cwd:
28
+
29
+ - global `defaultProjectTrust=ask` (default) and `never` both **ignore** project
30
+ `.pi/settings.json`, project resources, and project extension discovery;
31
+ - only `always`, a saved `/trust` decision, or a one-shot `--approve` trusts the
32
+ project side.
33
+
34
+ **Ops:** to load project settings / project extensions in an RPC child, either
35
+ `/trust` that cwd first (writes trust.json) or set `defaultProjectTrust=always`.
36
+ This matters most when `inheritExtensions: true` (or
37
+ `PI_LEGION_CHILD_INHERIT_EXTENSIONS`) re-enables discovery; the default
38
+ `--no-extensions` already blocks most silent project-extension loads.
39
+
40
+ ### Opt-in `--approve` (legion settings)
41
+
42
+ ```json
43
+ {
44
+ "subagents": {
45
+ "rpc": {
46
+ "approve": true
47
+ }
48
+ }
49
+ }
50
+ ```
51
+
52
+ Default is **false**: spawn argv does **not** pass `--approve`, `-a`, or
53
+ `--no-approve`. When `subagents.rpc.approve` is `true`, legion appends
54
+ `--approve` to the child argv. There is no inherit-parent-trust channel; the
55
+ child still reads the same agent home `trust.json` / `defaultProjectTrust` as
56
+ any other pi process on the machine. v1 does not emit `--no-approve`.
57
+
58
+ ## Two approval layers (do not merge)
59
+
60
+ 1. **Upstream pi tool-approval** — interactive TUI gate for tool calls. Headless
61
+ modes have **no** interactive tool-approval UI; “ask” trust without a saved
62
+ decision behaves like ignoring project settings (see above), which is not the
63
+ same as a confirm dialog.
64
+ 2. **Legion `onBlocked`** (`forward` / `auto-approve` / `notify`) — parent-side
65
+ **subagent block strategy** when a child surfaces an approval-class request
66
+ through `extension_ui`. It is **not** a replacement for pi interactive
67
+ tool-approval; keep the layers separate in docs and debugging.
68
+
69
+ ## Exit code / signal → `SettleResult` → node state
70
+
71
+ | Event | `SettleResult` | Orchestrator node |
72
+ | --- | --- | --- |
73
+ | RPC `agent_settled` | `settled:true`, `abnormal:false` (may set `aborted`) | settled |
74
+ | Process exit with `retireRequested` (incl. SIGTERM/SIGKILL escalate) | `settled:false`, `abnormal:false`, reason ≈ retired…, `exitCode?`, `signal?` | retired |
75
+ | Unexpected exit `exitCode===0` before settle | `settled:false`, `abnormal:true`, `exitCode:0` | gone → failed |
76
+ | Unexpected exit nonzero / `null` | `settled:false`, `abnormal:true`, `exitCode` | gone → failed |
77
+ | Unexpected signal (no retire) | `settled:false`, `abnormal:true`, `signal` | gone → failed |
78
+ | `waitSettled` timeout | `settled:false`, `abnormal:false`, `reason:"timeout"` | timeout (process still alive) |
79
+ | Parent abort of wait | `settled:false`, `abnormal:false`, `reason:"aborted by parent"` | aborted path |
80
+
81
+ v1 does **not** subclass failed by exit code (0 and nonzero are both abnormal);
82
+ `exitCode` / `signal` are asserted on the result for diagnostics. Parent-session
83
+ hygiene (#181) is orthogonal and unchanged by this contract.
@@ -0,0 +1,140 @@
1
+ # In-process AgentSession mode (#197)
2
+
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
6
+ `spawnMode: "inprocess"` combined with `worktree: true` is still **refused**
7
+ with `INVALID_PARAMS` — the fallback only covers the implicit default.
8
+
9
+ ## When to use
10
+
11
+ | Mode | Good for | Avoid when |
12
+ |---|---|---|
13
+ | `inprocess` (default; auto-fallback below) | Short, trusted fan-out; lower spawn latency; simpler approval / UI fan-in | Need OS worktree, separate credentials, crash isolation |
14
+ | `rpc` (fallback: worktree / non-pi kind; or explicit `spawnMode: rpc`) | Long runs, untrusted tasks, need crash / env / cwd isolation, **worktree** | Cold-start cost dominates |
15
+
16
+ There is no single global default in the config sense: omitting `spawnMode`
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.
20
+
21
+ **Non-goals:** no OS worktree isolation; no promise of process crash containment; OpenAaaS Go in-process transport is out of scope.
22
+
23
+ ## Config
24
+
25
+ Per agent (frontmatter → `agentOverrides` → team member override):
26
+
27
+ ```yaml
28
+ # agents/scout.md
29
+ ---
30
+ name: scout
31
+ description: Fast read-only recon
32
+ spawnMode: inprocess # omit = inprocess (worktree roles auto-fall back to rpc)
33
+ worktree: false # explicit inprocess + worktree: true is still refused
34
+ ---
35
+ ```
36
+
37
+ ```json
38
+ {
39
+ "subagents": {
40
+ "agentOverrides": {
41
+ "scout": { "spawnMode": "inprocess" }
42
+ },
43
+ "inprocess": {
44
+ "maxSpawnsPerSession": 32,
45
+ "maxDepth": 2,
46
+ "maxActive": 8,
47
+ "maxChildrenPerNode": 4,
48
+ "waitTimeoutMs": 300000
49
+ }
50
+ }
51
+ }
52
+ ```
53
+
54
+ ### Limit keys (independent buckets)
55
+
56
+ | Semantic | RPC | In-process |
57
+ |---|---|---|
58
+ | Spawns / session | `maxSubagentSpawnsPerSession` | `inprocess.maxSpawnsPerSession` (no default) |
59
+ | Tree depth | `legion.maxDepth` | `inprocess.maxDepth` (AsyncLocalStorage; default **4**) |
60
+ | Concurrent alive | `legion.maxActiveNodes` | `inprocess.maxActive` (default **30**; **8** when the block is present without `maxActive`) |
61
+ | Children / node | `legion.maxChildrenPerNode` | `inprocess.maxChildrenPerNode` (default **8**) |
62
+ | Wait timeout | agent `timeoutMs` / wait default | `inprocess.waitTimeoutMs` (no default) |
63
+
64
+ Never fall back across columns: in-process limits are read from
65
+ `subagents.inprocess.*` only, never from the `legion.*` keys.
66
+
67
+ Omitting an `inprocess.*` key is **not** unlimited. The documented defaults
68
+ mirror `legion.*`, so a deployment with no `subagents.inprocess` block at all
69
+ still runs under `maxDepth: 4` / `maxChildrenPerNode: 8` / `maxActive: 30`.
70
+ The two exceptions: with an explicit block that omits `maxActive`, it is 8;
71
+ and `maxSpawnsPerSession` / `waitTimeoutMs` have no default (unlimited).
72
+
73
+ ## Identity channel: in-process context (AsyncLocalStorage)
74
+
75
+ In-process children share the parent's `process.env` — they are sessions, not
76
+ processes. Nothing about `PI_SUBAGENT_*` or `PI_LEGION_*` can distinguish a
77
+ child from its parent, and reading env in a child yields the **parent's**
78
+ values (or nothing at all when the parent is root). Binding child identity to
79
+ env therefore mis-scoped role enforcement: a parent `writer` silently dropped
80
+ a read-only child's write bans, and a parent read-only role wrongly banned a
81
+ `writer` child.
82
+
83
+ So in-process identity travels in an `AsyncLocalStorage` child context instead:
84
+ node id, db path, acceptance role, `allowNested`, `utility`, `spawnMode`, and
85
+ the nested parent path. The guards and the legion runtime read that context, and
86
+ only fall back to env for RPC children, whose process really does own its env.
87
+
88
+ Two consequences worth knowing:
89
+
90
+ - **ALS does not cross `spawn()`.** A real subprocess started from a child
91
+ (`bash npm test`, another CLI, a nested agent process) cannot inherit the
92
+ tree identity. That is the isolation the env channel lacked — it is what
93
+ produced the "healthy ghost nodes" incident, where a test-loading extension
94
+ adopted an ancestor's ledger identity.
95
+ - **The SDK never emits `session_start` for these sessions.** `createAgentSession`
96
+ does not call `bindExtensions`, which is the only emitter, so the usual
97
+ session-start identity init is dead here. The orchestrator injects the
98
+ context explicitly and the extension attaches the identity on its first
99
+ ctx-bearing event, using the pre-created transcript file as the credential.
100
+
101
+ Do not replace this with env injection for in-process children.
102
+
103
+ ## Architecture
104
+
105
+ ```
106
+ Orchestrator
107
+ → RoutingSupervisor (LegionSupervisor)
108
+ ├─ spawnMode rpc → RpcSupervisor (unchanged)
109
+ └─ spawnMode inprocess → InProcessSupervisor
110
+ createAgentSession + SessionManager.inMemory
111
+ (tools / model / systemPrompt wired)
112
+ ```
113
+
114
+ `InProcessSupervisor` implements the full contract: `spawnChild` / `prompt` / `steer` / `followUp` / `abort` / `waitSettled` / `isAlive` / `stats` / `retire`. Retire calls `session.dispose()` — no SIG / descendant sweep. Events fan-in with the same coarse types as RPC (`agent_*`, `turn_*`, `tool_execution_*`) for rail / TUI. OSC hierarchical activity (#166) may be stubbed here.
115
+
116
+ ## Session factory (v1 whitelist)
117
+
118
+ Production `defaultSessionFactory` calls `createAgentSession` with:
119
+
120
+ | Option | Source |
121
+ |---|---|
122
+ | `tools` | `agent.tools` (omit when unset → SDK default). Non-utility roles also get `vexillatio`, same as RPC `pushToolArgs`. |
123
+ | `model` / `thinkingLevel` | Resolved spawn `model` (+ `thinking` / `modelProvider`), via `ModelRuntime` + `resolveCliModel` — same string Orchestrator already picked for RPC. |
124
+ | system prompt | `DefaultResourceLoader` `systemPromptOverride` (replace) or `appendSystemPromptOverride` (append) from `agent.systemPrompt` + `systemPromptMode`. `childContext` is joined once here (RPC-aligned); do not double-inject. |
125
+ | extensions | Default `noExtensions: true` + explicit `additionalExtensionPaths` from role / `PI_LEGION_CHILD_EXTENSIONS` / auto `builtin:codemode` + child-guard. Does **not** reload parent MCP wholesale. `inheritExtensions: true` restores host discovery. |
126
+
127
+ See `buildInProcessSessionPlan` / `test/unit/inprocess-supervisor.test.ts`.
128
+
129
+ ## Refusals
130
+
131
+ - `kind !== "pi"` → `INVALID_PARAMS` regardless of `spawnMode`: only `pi` roles
132
+ are supported, so a non-`pi` role is refused up front and never reaches the
133
+ worktree fallback
134
+ - `worktree: true` + explicit `inprocess` → `INVALID_PARAMS` (an omitted
135
+ `spawnMode` falls back to `rpc` instead)
136
+ - Invalid `spawnMode` string → loud throw at settings / frontmatter parse
137
+
138
+ ## Tests
139
+
140
+ See `test/unit/inprocess-supervisor.test.ts` (mock session) and settings parse coverage in `test/unit/spawn-mode-settings.test.ts`.