pi-legion 5.0.0 → 5.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -11,6 +11,86 @@ summary so you can see **2.x vs 0.16** without leaving the tree.
11
11
 
12
12
  ## [Unreleased]
13
13
 
14
+ ## [5.0.1] — 2026-10-27
15
+
16
+ Patch release. **This is the release where 5.0.0's extension-inheritance policy
17
+ starts actually taking effect** — upgrading changes what a child loads, so read
18
+ the first entry before rolling it out to a fleet.
19
+
20
+ ### Fixed
21
+
22
+ - **Host extensions are inherited again (#280).** 5.0.0 declared "children inherit
23
+ every host extension by default" and documented the narrowing knobs, but the
24
+ policy never reached the spawn path: `discoverHostExtensions()` had no callers,
25
+ the sync getter it fed therefore returned `undefined`, and
26
+ `inheritedExtensions` was always handed an empty host list. Children kept
27
+ getting exactly the card's own `extensions` plus the child-guard — the
28
+ `--no-extensions` behaviour #280 set out to remove, under a new description.
29
+
30
+ Discovery is now warmed once per process before a child is spawned. The cache
31
+ lives on `globalThis` under `Symbol.for` rather than in a module-level `let`,
32
+ because pi re-evaluates an extension module with `moduleCache: false` for a
33
+ child's `--extension` load: a module-level variable is invisible across those
34
+ instances, so a child spawned from a re-loaded instance would have inherited
35
+ nothing. The same escape hatch already backs `child-context.ts`,
36
+ `inprocess-depth.ts` and `admission-queue.ts`.
37
+
38
+ The warm-up sits in each backend's `spawnChild` (and in `RoutingSupervisor`,
39
+ which sees both). It is deliberately not in `Orchestrator.launch`: that fast
40
+ path may not gain an await before the synchronous admission block
41
+ (`launch-admission-queue-v1` §13.7).
42
+
43
+ - **`PI_LEGION_CHILD_INHERIT_EXTENSIONS=0` works on the inherited half (#280).**
44
+ `collectChildExtensions` accepted an `env` argument and then did not forward it
45
+ to `inheritedExtensions`, so the operator kill-switch applied everywhere except
46
+ the set it was meant to disable. Harmless while that set was always empty;
47
+ wrong the moment it is not.
48
+
49
+ - **A manifest that names a directory no longer breaks rpc children (#280).**
50
+ `pi.extensions: ["./extensions"]` (what `pi-diff-review` ships) resolved to the
51
+ directory itself, and `pi -e <dir>` exits 1 with `Failed to load extension:
52
+ Cannot find module` — so wiring inheritance up would have failed every rpc
53
+ child of a session with that package installed. Declared entries now resolve the
54
+ way pi's own loader does: a file as-is, a directory through its own
55
+ `pi.extensions`, else its `index.ts` / `index.js`, else its `*.ts` / `*.js`
56
+ files. An entry that expands to nothing is dropped instead of leaving an
57
+ unloadable path.
58
+
59
+ - **`extensions/<name>/index.ts` is discovered (#280).** The first sweep collected
60
+ only files directly inside a declared directory, so pi's documented
61
+ subdirectory layout resolved to nothing and the package silently contributed
62
+ zero extensions. The sweep now matches pi's `collectAutoExtensionEntries`:
63
+ `.`-prefixed names and `node_modules` are skipped, `*.ts` / `*.js` files are
64
+ collected, and a subdirectory is resolved as a package of its own (one level,
65
+ as pi does).
66
+
67
+ - **A failing `pi list` is retried, and costs one probe per spawn (#280).** The
68
+ old shape cached the failure as `[]` permanently, so one transient error
69
+ disabled inheritance until the process restarted. Failures now stamp
70
+ `failedAt` and short-circuit for `FAILURE_TTL_MS` (5 s), which keeps a
71
+ three-point warm-up from probing twice on the same spawn while still retrying
72
+ once the window passes.
73
+
74
+ - **A direct `RpcSupervisor` gets the same warm-up (#280).** `api.ts` documents
75
+ `new Orchestrator({ supervisor: new RpcSupervisor(), … })`, which never touches
76
+ `RoutingSupervisor`; warming only there left that path inheriting nothing.
77
+
78
+ ### Notes
79
+
80
+ - **Cost now matches what 5.0.0 promised.** Inheriting everything includes
81
+ pi-lens (a ~5.7 MB bundle that starts an LSP stack) on every child. In-process
82
+ children share the host process, so this is host memory, not a new process.
83
+ Add `excludeExtensions: [pi-lens]` to a card (or
84
+ `PI_LEGION_CHILD_INHERIT_EXTENSIONS=0` globally) if a fleet does not need it —
85
+ before this release those knobs were inert.
86
+ - **This package is never inherited into its own children.** In the source-tree
87
+ layout `pi list` and the child-guard name different files for the same code, so
88
+ dedupe could not collapse them and a child loaded two legion factories
89
+ (`Tool "subagent" conflicts with …`). The guard is still appended
90
+ unconditionally at the end.
91
+ - Doctor output and the README described the intended behaviour, which is now the
92
+ actual behaviour; they were not rewritten for this release.
93
+
14
94
  ## [5.0.0] — 2026-10-26
15
95
 
16
96
  Major because upgrading **can break an existing role card**: unknown frontmatter
@@ -0,0 +1,428 @@
1
+ # 冻结契约:extension inheritance 接线(#280 的漏接线修复)
2
+
3
+ 状态:**FROZEN** — 实施中禁止偏离。发现矛盾立刻停下报告,不要自圆其说。
4
+ 范围:implementation-ready。本文件冻结接口、落点、禁令与验收。
5
+
6
+ ## 0. 目标与不做的事
7
+
8
+ **目标**:让 5.0.0 已声明的策略「children inherit every host extension by default
9
+ (#280)」在运行时真正生效。当前 `discoverHostExtensions()` 全仓零调用,
10
+ `discoveredHostExtensions()` 恒为 `undefined`,`inheritedExtensions` 恒收到空 host
11
+ 列表,策略层存在但等于空转。
12
+
13
+ **顺带修掉两个独立缺陷**(它们被空 discovery 掩盖,接线后会立刻显形):
14
+
15
+ - `collectChildExtensions(agent, env)` 丢弃 `env`,导致 `PI_LEGION_CHILD_INHERIT_EXTENSIONS=0`
16
+ 对继承集无效(实测:`inheritedExtensions(agent, HOST, envOff)` → 0 条,但
17
+ `collectChildExtensions(agent, envOff)` → 7 条)
18
+ - legion 自身可能被当作「宿主扩展」注入子会话,与 child-guard 形成双份加载
19
+
20
+ **non-goals(本契约明确不做)**:
21
+
22
+ - 不改 `role-tool-gates.ts:79` 的「inheritsExtensions 为 true 时静音」策略。接线成功后该策略
23
+ 才真正成立;若宿主确实缺某扩展,静音仍是既有独立缺陷,另立 issue。
24
+ - 不改 doctor 的 `!inheritGlobal` 模型探测短路(`legion-doctor.ts:569/597/607`)。接线后
25
+ `inheritGlobal=true` 与「真的继承了」重新一致。
26
+ - 不修 `playbook.ts:14` / `docs/headless-child-contract.md` 等文案。文案与代码再次一致后
27
+ 这些描述自动成立。
28
+ - 不动 `--no-extensions` + 显式 `--extension` 的既有形状。rpc/inprocess 字节级对齐是
29
+ 冻结契约(`args.ts:249-255`、`inprocess-supervisor.ts:151-159`)。
30
+
31
+ ## 1. 已确认事实(父会话已核对,**禁止重新侦察**)
32
+
33
+ | # | 事实 | 证据 |
34
+ | --- | --- | --- |
35
+ | F1 | `discoverHostExtensions()` 全仓零调用;`resetExtensionDiscoveryCache()` 同样零调用 | `src/runs/extension-inheritance.ts:128`、`:70`(唯一定义处) |
36
+ | F2 | `discovered` 唯一赋值点在 `discoverHostExtensions` 内部 | `extension-inheritance.ts:144` |
37
+ | F3 | `discoveredHostExtensions()` 是纯 sync getter,返回模块级 `discovered` | `extension-inheritance.ts:156-158` |
38
+ | F4 | `collectChildExtensions` 把 `discoveredHostExtensions() ?? []` 传给 `inheritedExtensions`,**未传 env** | `src/runs/args.ts:217` |
39
+ | F5 | `childInheritsExtensions` 默认返回 `true`;env 只能降不能升(`=1` 是 no-op) | `extension-inheritance.ts:51-61`(实测三种卡片形态下 env=1 与默认完全相同) |
40
+ | F6 | 实测 `moduleCache:false` 下模块级 `let` **跨 jiti 实例不可见**,且**同实例重复 import 也重执行**(executions: 2) | jiti 探针 P1/P1h(见侦察报告) |
41
+ | F7 | 默认缓存下同一模块实例可跨 jiti 实例共享(`B 看到 A 的 6 条`) | jiti 探针 P1-default-cache |
42
+ | F8 | 本仓另三处**故意**用 `globalThis+Symbol.for` 对抗 jiti 重载 | `src/supervisor/child-context.ts:11-24`、`src/supervisor/inprocess-depth.ts:6-26`、`src/supervisor/admission-queue.ts:25-31` |
43
+ | F9 | `/reload` → `clearExtensionCache()` → 入口模块重新 import → `discovered` 归零 | `agent-session.js:3010` → `resource-loader.js:352-359` → `loader.js:89-93`、`:461-490`(实测每次 reload 重新 transpile) |
44
+ | F10 | 同 cwd 下新建子 loader **复用**父模块实例;cwd 变化才 `clearExtensionCache` | 探针 P3m(`A==B same cwd -> shared: true`、`A==C diff cwd -> re-imported`) |
45
+ | F11 | 安装副本形态下 guard 路径 === discovered legion 条目(`path.resolve` 后相等),dedupe 合并成 1 条 | 探针 P2(`dedupeKeys equal? true`、`collectChildExtensions count: 1`) |
46
+ | F12 | 源码树自举形态下两者不等,出现**两个 legion factory**并报 tool 冲突 | 探针 P2h(`legion factories: 2`、`Tool "subagent" conflicts with /root/code/...`) |
47
+ | F13 | `extensionDedupeKey` 只做 `path.resolve`(builtin 除外) | `src/runs/args.ts:181-183` |
48
+ | F14 | `matchesExcludedExtension` 支持包名形式,`pi-legion` 匹配安装副本与源码树两种路径 | `extension-inheritance.ts:170-195` |
49
+ | F15 | `launch()` 774→1072 只有 2 个 await(`:1005`、`:1072`),均在同步准入块 `:938-955` 之后 | 探针 P4 + 源码 |
50
+ | F16 | 契约禁令:禁止让 `launch()` 的快路径多出任何 await | `docs/contracts/launch-admission-queue-v1.md:829` |
51
+ | F17 | `LaunchInput` 无 `signal`/`abort` 字段;`launch()` 内无 abort 读取 | `orchestrator.ts:180-196`;P4 |
52
+ | F18 | `RoutingSupervisor.spawnChild` 是唯一分派到两个后端的点,且**本身 async** | `src/supervisor/routing-supervisor.ts:128-160` |
53
+ | F19 | 唯一 spawn 漏斗是 `Orchestrator.launch` → `supervisor.spawnChild`(`:1072`) | `orchestrator.ts:774`、`:1072` |
54
+ | F20 | 存在 3 类不在 tool execute 帧内的 spawn 出口:postman 定时器、启动期团队恢复 microtask、launch 队列泵 | `legion-tools.ts:106-110`→`:184`→`index.ts:544`;`index.ts:642-649`→`:683`;`orchestrator.ts:1488`/`:1449-1452` |
55
+ | F21 | `pi list` 实测 320–920 ms;有 project 资源时内部会加载全部扩展(309 条 jiti 记录) | 探针 P4-timing |
56
+ | F22 | `discoverHostExtensions` 只 `execFile("pi",["list"])`,不 spawn 子 agent、不写 legion.db | `extension-inheritance.ts:128-152` |
57
+ | F23 | 失败也会被缓存成 `[]`,且 `if (discovered)` 对空数组为真 → 永不重试 | `extension-inheritance.ts:129`、`:143-151` |
58
+ | F24 | 会因 discovery 接上而变红的现有测试 | `test/unit/args.test.ts:43,40,128,185`;`test/unit/legion-doctor.test.ts:261`(详见 §6) |
59
+ | F25 | 脚本:`npm run typecheck` / `npm test` / `npm run test:integration` | `package.json:52-54` |
60
+
61
+ ## 2. 决策(D 编号,reviewer 必读)
62
+
63
+ ### D1 — 缓存迁到 `globalThis[Symbol.for]`
64
+
65
+ **判定**:`discovered` / `discovery` 从模块级 `let` 迁到
66
+ `globalThis[Symbol.for("pi-legion.extension-discovery")]` 上的一个 holder 对象。
67
+
68
+ **理由**:F6 实测证明模块级 `let` 在 pi 的真实 loader 配置(`moduleCache:false`)下跨 jiti
69
+ 实例不可见、同实例重 import 也归零。若继续用模块级变量,子会话(cwd 不同时触发
70
+ `clearExtensionCache`,F10)内的 spawn 会读到空的 `discovered`,「父预热一次惠及全树」不成立。
71
+ F8 是本仓库已验证的同因先例。
72
+
73
+ **失败模式**:不做迁移 → 探针 P1-noCache 的形态(`B.discovered: undefined`)在生产复现,
74
+ 修复只对「第一次 spawn」有效。
75
+
76
+ **验收**:A1、A2。
77
+
78
+ #### D1-a — holder 形状与复位语义
79
+
80
+ ```ts
81
+ const STORE_KEY = Symbol.for("pi-legion.extension-discovery");
82
+
83
+ interface DiscoveryStore {
84
+ /** Resolved list, or undefined until discovery completes. */
85
+ discovered?: string[];
86
+ /** In-flight promise, so concurrent launches share one `pi list`. */
87
+ inFlight?: Promise<string[]>;
88
+ }
89
+
90
+ type StoreHolder = { [key: symbol]: DiscoveryStore | undefined };
91
+ const store = ((globalThis as unknown as StoreHolder)[STORE_KEY] ??= {});
92
+ ```
93
+
94
+ `resetExtensionDiscoveryCache()` 必须 **清空同一个 holder 的字段**(`delete store.discovered;
95
+ delete store.inFlight;`),不得重新赋值 holder —— 否则父会话与子会话会指向两个不同对象
96
+ (F6 的直接后果),`reset` 在测试外的语义也会静默失效。
97
+
98
+ ### D2 — 预热落点:`RoutingSupervisor.spawnChild`,不是 `Orchestrator.launch`
99
+
100
+ **判定**:在 `RoutingSupervisor.spawnChild` 顶部(`resolveSpawnMode` 之后、分派之前)
101
+ `await ensureHostExtensions()`。**不得**在 `Orchestrator.launch` 顶部预热。
102
+
103
+ **理由**:
104
+
105
+ - F16 是**已冻结契约的禁令**:`launch()` 快路径不得多出 await。F15 显示这段只有 2 个
106
+ await 且都在同步准入块之后,插到准入块之前会破坏 A21(同 tick 8 步 fanout 的槽位预留)。
107
+ - F18:`RoutingSupervisor.spawnChild` 本身 async、已在 await 链上、且是唯一同时看见两后端的点。
108
+ - F19 + F20:`Orchestrator.launch` 是唯一 spawn 漏斗,`RoutingSupervisor.spawnChild` 是它唯一
109
+ 的下游分派点 —— 三类后台出口(postman 定时器 / 团队恢复 microtask / 队列泵)全部经过它,
110
+ 因此预热覆盖全部 spawn 路径。
111
+ - F21:预热最坏 920 ms 且只在**首次**发生(之后 holder 命中)。放在分派点不影响准入判定。
112
+
113
+ **失败模式**:放到 `launch()` → 违反 F16,A21 测试变红;放到 `index.ts` 的 tool execute →
114
+ 漏掉 F20 的三类后台出口。
115
+
116
+ **验收**:A3、A4。
117
+
118
+ #### D2-a — `ensureHostExtensions` 契约
119
+
120
+ ```ts
121
+ /**
122
+ * Resolve the host's extension set once, for the whole process.
123
+ *
124
+ * Never throws and never rejects: discovery failure must not fail a launch
125
+ * (F22/F23). Returns the resolved list, or `[]` when discovery failed.
126
+ */
127
+ export async function ensureHostExtensions(env?: NodeJS.ProcessEnv): Promise<string[]>;
128
+ ```
129
+
130
+ - 幂等:并发调用共享同一个 in-flight promise(与既有 `discovery ??=` 语义一致)。
131
+ - 已缓存则立即返回缓存(不跑 `pi list`)。
132
+ - **失败缓存策略变更**:`pi list` 失败时**不缓存空数组**,只清 in-flight,让下一次 spawn 重试。
133
+ 理由 F23:既有实现把 `[]` 永久缓存,一次瞬时失败会静默关闭继承直到进程重启。
134
+ - `env` 透传给 `execFile`(既有行为),但**缓存键不区分 env** —— 保持"进程内一份主机扩展集"语义。
135
+
136
+ **验收**:A5。
137
+
138
+ ### D3 — env 透传修复
139
+
140
+ **判定**:`collectChildExtensions` 把 `env` 传给 `inheritedExtensions`。
141
+
142
+ ```ts
143
+ ...inheritedExtensions(agent, discoveredHostExtensions() ?? [], env),
144
+ ```
145
+
146
+ **理由**:F4 实测 `PI_LEGION_CHILD_INHERIT_EXTENSIONS=0` 目前对继承集无效。接线后 doctor 里
147
+ 按 env 构造的场景(`legion-doctor.test.ts` 多处)会连锁失败。
148
+
149
+ **失败模式**:不修 → opt-out 在生产无效,用户设 `=0` 却仍拿到全部宿主扩展(接线后)。
150
+
151
+ **验收**:A6。
152
+
153
+ ### D4 — legion 自身从继承集排除
154
+
155
+ **判定**:`collectChildExtensions` 在合并前把 legion 自身从 inherited 集合里滤掉:
156
+
157
+ ```ts
158
+ const inherited = inheritedExtensions(agent, discoveredHostExtensions() ?? [], env)
159
+ .filter((ext) => !matchesExcludedExtension(ext, SELF_EXTENSION_NAME));
160
+ ```
161
+
162
+ 其中 `SELF_EXTENSION_NAME` 是包名常量(`"pi-legion"`)。child-guard 仍在
163
+ 函数末尾**无条件**追加,因此本判定不影响 guard 的必然存在。
164
+
165
+ **理由**:F11 显示安装副本形态本来就会 dedupe 成 1 条;F12 显示自举形态会出现两个 factory
166
+ 并报 tool 冲突(`Tool "subagent" conflicts with ...`)。自排除让两种形态都收敛到 1 条。
167
+
168
+ **不做的事**:不依赖 `extensionDedupeKey` 做路径归一化。F13 显示它只做 `path.resolve`,
169
+ 无法识别"同一份代码的两个副本"(F12 实测 inode 不同)。
170
+
171
+ **验收**:A7。
172
+
173
+ ### D5 — 不新增 await 到 `launch()`,不碰 doctor 策略
174
+
175
+ 见 non-goals 与 F16。`legion-doctor.ts` 的 `extensionsForRole` 占位串
176
+ `"(all host-discovered extensions)"` 接线后重新成立,**不改**。
177
+
178
+ **验收**:A8(`launch()` 的 await 数量与位置不变)。
179
+
180
+ ## 3. 文件归属(单 writer,禁止越界)
181
+
182
+ | 文件 | 允许的改动 |
183
+ | --- | --- |
184
+ | `src/runs/extension-inheritance.ts` | D1(holder 迁移)、D2-a(新增 `ensureHostExtensions`);`discoverHostExtensions` 失败不缓存空数组 |
185
+ | `src/runs/args.ts` | D3(第 217 行补 env)、D4(自排除 + 常量) |
186
+ | `src/supervisor/routing-supervisor.ts` | D2(`spawnChild` 顶部 `await ensureHostExtensions()`) |
187
+ | `test/unit/extension-inheritance.test.ts` | A1、A2、A5 新增用例 |
188
+ | `test/unit/args.test.ts` | A6、A7 新增用例;修 F24 的 4 条 |
189
+ | `test/unit/legion-doctor.test.ts` | 修 F24 的 1 条(`:261`) |
190
+
191
+ **禁止改动**:`src/runs/orchestrator.ts`、`src/supervisor/inprocess-supervisor.ts`、
192
+ `src/supervisor/rpc-supervisor.ts`、`src/extension/legion-doctor.ts`(生产代码)、
193
+ `src/runs/role-tool-gates.ts`、`docs/contracts/launch-admission-queue-v1.md`。
194
+
195
+ ## 4. 验收清单
196
+
197
+ | # | 断言 | 方式 |
198
+ | --- | --- | --- |
199
+ | A1 | 迁移后 `discoveredHostExtensions()` 仍能读到 `ensureHostExtensions()` 的结果 | 单测:`await ensureHostExtensions()` 后 `discoveredHostExtensions()` 非 undefined |
200
+ | A2 | `resetExtensionDiscoveryCache()` 清空的是**同一个** holder(不是换新对象) | 单测:reset 前拿到 holder 引用,reset 后 `discoveredHostExtensions()` 为 undefined 且引用不变 |
201
+ | A3 | `RoutingSupervisor.spawnChild` 在分派前 await 预热 | 单测:stub `ensureHostExtensions`,断言调用发生在 backend.spawnChild 之前 |
202
+ | A4 | 预热幂等:连续 2 次 spawn 只跑一次 `pi list` | 单测:计数 stub,断言恰好 1 次 |
203
+ | A5 | 预热失败不抛、不缓存空结果(第二次会重试) | 单测:stub execFile 先失败后成功,断言第 2 次调用拿到结果 |
204
+ | A6 | `collectChildExtensions(agent, {PI_LEGION_CHILD_INHERIT_EXTENSIONS:"0"})` 不含 inherited 条目 | 单测:primed discovery 后断言 |
205
+ | A7 | inherited 集合不含 legion 自身,但 child-guard 仍在末尾 | 单测:断言 `!list.includes(selfPath) && list.includes(childGuardExtensionPath())` |
206
+ | A8 | `orchestrator.ts` 的 774→1072 区间 await 仍只有 `:1005`(内容可漂)与 `spawnChild` 那一处 | `grep -c await` 对照 + 契约禁令 5 复核 |
207
+ | A9 | 全量单测绿 | `npm run typecheck && npm test && npm run test:integration` |
208
+ | A10 | 真实端到端:primed 后 `collectChildExtensions` 含宿主扩展(pi-lens 等) | 内联 `node -e` 探针,输出与 F11 形状一致 |
209
+
210
+ ## 5. 实施顺序
211
+
212
+ 1. D1 holder 迁移(不改变行为,先让缓存可跨实例)
213
+ 2. D2-a `ensureHostExtensions`(含失败不缓存)
214
+ 3. D3 env 透传 → D4 自排除(两处都在 `args.ts`,一次改完)
215
+ 4. D2 预热点接入
216
+ 5. 修 F24 的 5 条测试 + 新增 A1–A7
217
+
218
+ 第 1 步之后跑一次 `npm test` 确认零红,再继续 —— 否则后续失败无法归因。
219
+
220
+ ## 6. 已知会变红的测试(F24,必须一并处理)
221
+
222
+ | 测试 | 位置 | 变红原因 |
223
+ | --- | --- | --- |
224
+ | `buildPiArgs injects the child-guard extension` | `args.test.ts:30`,断言 `:43-45` | `--extension` 计数从 1 变 7;第一个值不再是 guard |
225
+ | 同上 `:39-41` | 取第一个 `--extension` 值 | 同上 |
226
+ | `#43 children get --no-extensions by default` | `args.test.ts:124`,`deepEqual` `:128` | 全量数组顺序敏感 |
227
+ | `#89 collectChildExtensions dedupes explicit builtin:codemode` | `args.test.ts:178`,`deepEqual` `:185-188` | 同上 |
228
+ | `#70 bundled worker does not promise lens tools` | `legion-doctor.test.ts:230`,断言 `:261` | discovered 含 `pi-lens/dist/index.js` |
229
+
230
+ **修复方式**:这些测试断言的是「discovery 为空时」的列表形状。改为**显式控制 discovery
231
+ 状态**:在 `withEnv` 里额外 `resetExtensionDiscoveryCache()`,并对需要完整列表的用例断言
232
+ 「inherited 条目存在 + guard 在末尾」而不是全量 `deepEqual`。
233
+
234
+ **禁止**:用 `excludeExtensions: ["*"]` 或伪造空 host 来让旧断言继续通过 —— 那是把 F24
235
+ 的假设重新固化成契约。
236
+
237
+ ---
238
+
239
+ ## 7. 修订 R1(review 后追加,2026-10-26)
240
+
241
+ 本节由 reviewer 审查 `fix/280-extension-inheritance-wiring` 后追加。**修订原因是实施中发现了
242
+ F1–F25 未能预见的两条真实缺陷**;F 事实表保持不变,A 清单在其后增量。
243
+
244
+ ### R1-a — 目录形态的 `pi.extensions` 条目必须展开成可加载文件(critical)
245
+
246
+ **新事实**:
247
+
248
+ | # | 事实 | 证据 |
249
+ | --- | --- | --- |
250
+ | F26 | `pi-diff-review` 的 `package.json` 声明 `pi.extensions: ["./extensions"]`,该目录内只有 `review.ts`、无 `package.json`/`index.ts` | `node -e require('.../pi-diff-review/package.json').pi` + `ls` |
251
+ | F27 | `resolveExtensionEntries` 对声明条目只做 `existsSync` 过滤,**不过滤目录** | `extension-inheritance.ts:130-134`(接线后实测 `非文件条目: [".../pi-diff-review/extensions"]`) |
252
+ | F28 | 真实 `pi -e <该目录> --print` → **exit 1**(`Failed to load extension: Cannot find module`);换成 `-e <该目录>/review.ts` → **exit 0** | 实测两次退出码 |
253
+ | F29 | in-process 后端不致命但静默丢弃:`DefaultResourceLoader({additionalExtensionPaths:[该目录]})` → `errors: 1, loaded: 0`,且 `inprocess-supervisor.ts` 只 warn `pendingProviderRegistrations`,读不到 `getExtensions().errors` | SDK 探针 |
254
+
255
+ **判定**:`resolveExtensionEntries` 必须把目录条目展开成其内可加载的 `*.ts|*.js`,
256
+ 镜像 pi 自己的 loader 行为;展开为空则丢弃该条目。
257
+
258
+ **为什么这不违反 non-goal 5**:non-goal 5 指「不修 `pi.extensions` 写成目录而非文件」这个
259
+ **拼写选择**。本节不改变那个选择,只保证交给 `-e` 的东西可加载。接线前该缺陷不可达(F3),
260
+ 接线后它变成「每个用 pi-diff-review 的 rpc 子会话启动失败」,属改动自身引入的回归。
261
+
262
+ **新增验收**:
263
+
264
+ | # | 断言 |
265
+ | --- | --- |
266
+ | A11 | `pi.extensions: ["./extensions"]` 且目录内只有 `review.ts`(无 index/package.json)→ 解析结果是 `.../extensions/review.ts`,不是目录 |
267
+ | A12 | `pi.extensions` 指向空目录 → 该条目被丢弃,不出现在 argv |
268
+ | A13 | `collectChildExtensions` 返回值中**不存在**非文件路径(用 `statSync().isFile()` 全量断言) |
269
+
270
+ ### R1-b — 预热点必须覆盖直连 `RpcSupervisor` 的公开路径(major)
271
+
272
+ **新事实**:
273
+
274
+ | # | 事实 | 证据 |
275
+ | --- | --- | --- |
276
+ | F30 | `api.ts:13` 的文档示例是 `new Orchestrator({ supervisor: new RpcSupervisor(), ... })`,绕过 `RoutingSupervisor` | 读 `src/api.ts:8-20` |
277
+ | F31 | `Orchestrator` 构造函数的 supervisor 默认值也是裸 `RpcSupervisor` | `orchestrator.ts:453` |
278
+ | F32 | `index.ts` 的 5 处 `new Orchestrator` **全部**传 `input.supervisor`(来自 `RoutingSupervisor`),故自举形态覆盖正常 | `grep -A6 "new Orchestrator" index.ts`(736/1832/1930/2422/3116) |
279
+
280
+ **判定**:预热点从 `RoutingSupervisor.spawnChild` **下沉**到两个后端各自的
281
+ `spawnChild` 顶部(`InProcessSupervisor.spawnChild`、`RpcSupervisor.spawnChild`)。
282
+ 理由:这是无论调用方走 router 还是直连后端都必须经过的点,且两者本身都是 async。
283
+ `RoutingSupervisor.spawnChild` 的预热**保留**——它能在参数校验失败前就暖好缓存,
284
+ 且 router 路径下第一次调用会命中已暖的 holder(幂等,无额外 `pi list`)。
285
+
286
+ **为什么不放 `Orchestrator.launch`**:F16 禁令不变,`launch()` 快路径不得新增 await。
287
+
288
+ **新增验收**:
289
+
290
+ | # | 断言 |
291
+ | --- | --- |
292
+ | A14 | 直接 `new RpcSupervisor()` + `new Orchestrator({supervisor})` 后 `launch()`,`discoveredHostExtensions()` 非 undefined |
293
+ | A15 | 同上,in-process 后端 |
294
+ | A16 | 幂等不变:router 路径下连续 2 次 spawn 仍只 1 次 `pi list` |
295
+
296
+ ### R1-c — 驳回的 finding(记录以免重复争议)
297
+
298
+ reviewer 报的 minor「自排除会误吃 `acme/src/legion/index.ts`」**不成立**:
299
+ 自排除常量是 `"pi-legion"`,`matchesExcludedExtension` 的 `dir === target` /
300
+ `file === target.ts` 兜底只在目录/文件名恰为 `pi-legion` 时触发。实测 5 条路径,
301
+ 仅真正的 `node_modules/pi-legion/index.ts` 被丢:
302
+
303
+ ```
304
+ kept /host/node_modules/acme/src/legion/index.ts
305
+ kept /host/node_modules/acme/extensions/legion.ts
306
+ kept /host/node_modules/acme-legion/index.ts
307
+ kept /host/node_modules/acme/legion-ext/index.ts
308
+ DROPPED /host/node_modules/pi-legion/index.ts
309
+ ```
310
+
311
+ reviewer 用错了包名(拿 `legion` 当 target)。**不修。**
312
+
313
+ ### R1-d — 修订后的文件归属
314
+
315
+ 在 §3 基础上**新增允许改动**:
316
+
317
+ | 文件 | 允许的改动 |
318
+ | --- | --- |
319
+ | `src/runs/extension-inheritance.ts` | R1-a 的目录展开(`resolveExtensionEntries`) |
320
+ | `src/supervisor/inprocess-supervisor.ts` | **仅** R1-b:`spawnChild` 顶部 `await ensureHostExtensions()` |
321
+ | `src/supervisor/rpc-supervisor.ts` | **仅** R1-b:`spawnChild` 顶部 `await ensureHostExtensions()` |
322
+ | `test/unit/extension-inheritance.test.ts` | A11–A13 |
323
+ | `test/unit/routing-supervisor*.test.ts` 或 `test/unit/orchestrator*` 就近文件 | A14–A16 |
324
+
325
+ §3 的其余禁区不变(`orchestrator.ts` 仍禁止改,含 `launch()`)。
326
+
327
+ ---
328
+
329
+ ## 8. 修订 R2(第二次 review 后追加,2026-10-26)
330
+
331
+ 由 `rev-r1` 复审 `8cf2aba` 后追加。R1 的两条修复方向正确,但**镜像 pi loader 的目标未达到**,
332
+ 且三处预热点在失败路径上互相叠加。以下为增量事实与决策。
333
+
334
+ ### R2-a — 目录展开必须镜像 `collectAutoExtensionEntries`(major)
335
+
336
+ | # | 事实 | 证据 |
337
+ | --- | --- | --- |
338
+ | F33 | pi 处理 manifest 声明的目录走 `collectAutoExtensionEntries`,对每个**子目录**再调 `resolveExtensionEntries`,因此 `extensions/<name>/index.ts` 是官方文档化布局 | `package-manager.js:407-462`;`loader.js:623-631` 注释「规则 2: Subdirectory with index」 |
339
+ | F34 | 本实现在同一 fixture 上返回 `[]`:`extensions/alpha/index.ts` + `extensions/beta/index.ts` 全部静默丢失 | 实测 `resolveExtensionEntries("/tmp/extprobe/pkg")` → `[]` |
340
+ | F35 | `discoverExtensionFiles` 的注释自称「Deliberately one level」,理由是「嵌套包不需要,递归会把错拼路径变成无界遍历」 | `extension-inheritance.ts:160-168` |
341
+ | F36 | 同一实现把 `extensions/.eslintrc.js` **收进**结果;pi 的 `collectAutoExtensionEntries` 显式跳过 `.` 开头与 `node_modules` | `extension-inheritance.ts:176`;`package-manager.js:407-462` |
342
+ | F37 | 但 `pi -e <点文件>` 实测 **exit 0**(不是 exit 1),故 F36 的后果是「多传一条无用条目」而非启动失败 | 实测 `.eslintrc.js` → EXIT=0;`alpha/index.ts` → EXIT=0 |
343
+
344
+ **判定(R2-a)**:`discoverExtensionFiles` 改为与 pi 同形:
345
+
346
+ - 跳过 `.` 开头的条目与 `node_modules`
347
+ - 文件且 `*.ts|*.js` → 收
348
+ - **子目录** → 调 `resolveExtensionEntries(child, false)`,收到结果就收,收不到就跳过
349
+
350
+ F35 的「无界遍历」担忧用**深度上限**解决:`resolveExtensionEntries(dir, allowNestedManifest)` 的
351
+ 第二个参数(已存在)作为递归闸门,嵌套层只允许一层子目录解析,子目录内部不再继续递归。
352
+ 这与 pi 的行为一致(pi 也只递归一层:`collectAutoExtensionEntries` → `resolveExtensionEntries`
353
+ → 不再往下)。
354
+
355
+ **新增验收**:
356
+
357
+ | # | 断言 |
358
+ | --- | --- |
359
+ | A17 | `extensions/alpha/index.ts` + `extensions/beta/index.ts`(无 manifest/index 于 extensions 根)→ 两条结果都出现 |
360
+ | A18 | `extensions/.eslintrc.js` 不被收进结果 |
361
+ | A19 | 子目录内还是目录(`extensions/x/y/index.ts`)→ 不递归收,结果为空(或只收 `x` 层能解析到的) |
362
+
363
+ ### R2-b — 预热点在失败路径上必须幂等(major)
364
+
365
+ | # | 事实 | 证据 |
366
+ | --- | --- | --- |
367
+ | F38 | `ensureHostExtensions` 失败时不缓存(F23 的刻意设计,保留),故每次调用都会重跑 `pi list` | `extension-inheritance.ts:292-313` |
368
+ | F39 | 三处预热点(router + rpc 后端 + inprocess 后端)叠加后,router 路径一次 spawn 会 warm 两次:router 一次(失败)+ 后端一次(再失败) | 实测三次连续 `ensureHostExtensions` → `[[],[],[]]` |
369
+ | F40 | 失败时每个 spawn 恒付 2× 探测成本(真实 0.3–1.8s;`pi list` 超时时最坏 +30s),且发生在内存门之前 | `rev-r1` 实测(fake `pi` 500ms → spawn#1 1035ms / spawn#2 1026ms) |
370
+ | F41 | A16 的字面断言「router 路径连续 2 次 spawn 仍只 1 次 `pi list`」只在成功路径成立 | 同上 |
371
+
372
+ **判定(R2-b)**:引入**同 spawn 短路标记**,不动「失败不缓存跨 spawn 可重试」的语义:
373
+
374
+ ```ts
375
+ /**
376
+ * Mark the current spawn attempt as already-warmed, so the backend's warm call
377
+ * does not repeat a FAILED `pi list` that the router just paid for.
378
+ *
379
+ * Scope: one spawn attempt. Reset when the outermost warm returns.
380
+ */
381
+ ```
382
+
383
+ 实现方式(选一,实施者择简):
384
+
385
+ - **(a) 传入标记**:`ensureHostExtensions(env, { spawnScoped?: boolean })`;`RoutingSupervisor`
386
+ 调用后设置一个同步可见的 `recentlyWarmed` 时间戳/布尔,两个后端在**同一 tick 内**看到就短路。
387
+ - **(b) 失败短 TTL**:失败时把 `failedAt = Date.now()` 记进 holder,`ensureHostExtensions` 在
388
+ `FAILURE_TTL_MS`(如 5s)内直接返回 `[]` 而不重跑。跨 spawn 的最坏情况变成「首次失败后 5s 内的
389
+ spawn 都不重试」,比现状好,且仍满足 F23「不是永久关闭」。
390
+
391
+ **倾向 (b)**:实现最小(holder 加一个字段),无需在 router 与后端之间传状态,也不改变
392
+ `ensureHostExtensions` 的签名(A14/A15 的测试不用重写)。TTL 值必须是命名常量并注释理由。
393
+
394
+ **新增验收**:
395
+
396
+ | # | 断言 |
397
+ | --- | --- |
398
+ | A20 | `pi list` 失败下,router 路径一次 spawn 只跑 **1** 次 `pi list`(不是 2) |
399
+ | A21 | 失败后超过 `FAILURE_TTL_MS`,下一次 spawn 会**重试**(可恢复性未被永久关闭) |
400
+ | A22 | 成功路径行为不变:连续 2 次 spawn 仍只 1 次 `pi list`(既有 A16 保持绿) |
401
+
402
+ ### R2-c — 测试注释必须如实(minor)
403
+
404
+ `test/unit/extension-inheritance.test.ts:601-604` 的注释声称断言集合是「fake host **PLUS the
405
+ live `pi list`**」,但 `withPathEnv` 执行的是 `process.env.PATH = pathValue`(**完全替换**,
406
+ `test/unit/extension-inheritance.test.ts:235`),真实 `pi list` 从未运行。
407
+
408
+ **判定**:删掉「PLUS the live `pi list`」这半句。**不得**为此新增不做 PATH 遮蔽的用例 ——
409
+ 那会让测试依赖本机安装的包,不可移植。
410
+
411
+ ### R2-d — 修订后的文件归属增量
412
+
413
+ 在 §7 R1-d 基础上,**新增允许改动**:
414
+
415
+ | 文件 | 允许的改动 |
416
+ | --- | --- |
417
+ | `src/runs/extension-inheritance.ts` | R2-a 的 sweep 重写、R2-b 的失败 TTL |
418
+ | `test/unit/extension-inheritance.test.ts` | A17–A21、R2-c 的注释删除 |
419
+ | `test/unit/routing-supervisor*.test.ts` 或就近文件 | A20 |
420
+
421
+ `src/runs/orchestrator.ts`、`src/extension/legion-doctor.ts`、`src/runs/role-tool-gates.ts`、
422
+ `docs/contracts/launch-admission-queue-v1.md` 仍为禁区。
423
+
424
+ ### R2-e — 降级与驳回记录
425
+
426
+ - **降级**:`rev-r1` 的 minor「点文件导致 `pi -e` exit 1」论据不成立(实测 EXIT=0)。
427
+ 但「点文件不该被收」仍成立(pi 自己会跳过,多传一条无用条目)→ 并入 R2-a 的 A18。
428
+ - **保持驳回**:§7 R1-c 的「自排除误吃 `acme/src/legion/index.ts`」仍不成立,勿再报。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-legion",
3
- "version": "5.0.0",
3
+ "version": "5.0.1",
4
4
  "description": "Pi Legion v2: recursive centurion teams on Pi. Children run in-process by default (rpc only for a worktree-owning centurion); shared SQLite WAL legion.db. Declarative per-team phase machine (built-in default: planning/implementing/reviewing/verifying), parent/child/sibling legion_mail, three-layer /legion UI, upward MR trees, squad resume.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/runs/args.ts CHANGED
@@ -14,7 +14,7 @@ import {
14
14
  isThinkingLevel,
15
15
  } from "../shared/types.ts";
16
16
  import { formatChildTask } from "../extension/child-guard.ts";
17
- import { childInheritsExtensions, discoveredHostExtensions, inheritedExtensions } from "./extension-inheritance.ts";
17
+ import { discoveredHostExtensions, inheritedExtensions, matchesExcludedExtension } from "./extension-inheritance.ts";
18
18
 
19
19
  /** Re-exported for callers that only need the level list. */
20
20
  export { THINKING_LEVELS };
@@ -191,6 +191,9 @@ export function isCodemodeExtension(ext: string): boolean {
191
191
  return t === BUILTIN_CODEMODE || t.endsWith("/" + BUILTIN_CODEMODE);
192
192
  }
193
193
 
194
+ /** This package's name — its extension is never inherited, only guarded explicitly. */
195
+ export const SELF_EXTENSION_NAME = "pi-legion";
196
+
194
197
  /**
195
198
  * Effective `--extension` values for a child.
196
199
  *
@@ -213,8 +216,22 @@ export function collectChildExtensions(
213
216
  >,
214
217
  env: NodeJS.ProcessEnv = process.env,
215
218
  ): string[] {
219
+ // `env` MUST be forwarded: `PI_LEGION_CHILD_INHERIT_EXTENSIONS=0` is the
220
+ // operator kill-switch, and dropping it here made the opt-out a no-op for the
221
+ // inherited half of the list while still applying to everything else.
222
+ //
223
+ // Legion's own extension is filtered out of the inherited set. In the
224
+ // installed-copy layout `pi list` reports the same package the child-guard
225
+ // already names, and dedupe collapses them; in the source-tree layout the two
226
+ // paths differ, so the child would load TWO legion factories and die on
227
+ // `Tool "subagent" conflicts with ...`. Self-exclusion makes both layouts
228
+ // converge on exactly one, and the guard below still appends it
229
+ // unconditionally — so a hermetic card does not lose the guard either.
230
+ const inherited = inheritedExtensions(agent, discoveredHostExtensions() ?? [], env).filter(
231
+ (ext) => !matchesExcludedExtension(ext, SELF_EXTENSION_NAME),
232
+ );
216
233
  const list = [
217
- ...inheritedExtensions(agent, discoveredHostExtensions() ?? []),
234
+ ...inherited,
218
235
  ...(agent.extensions ?? []),
219
236
  ...(agent.subagentOnlyExtensions ?? []),
220
237
  ...childExtraExtensions(env),
@@ -61,25 +61,187 @@ export function childInheritsExtensions(
61
61
 
62
62
  const execFileAsync = promisify(execFile);
63
63
 
64
- /** Host-discovered package extension paths, resolved once per process. */
65
- let discovered: string[] | undefined;
66
- /** In-flight discovery, so concurrent launches share one `pi list`. */
67
- let discovery: Promise<string[]> | undefined;
64
+ /**
65
+ * Where the discovery cache lives: `globalThis` under `Symbol.for(...)`.
66
+ *
67
+ * A module-level `let` would be invisible to the other module *instances* in
68
+ * this process. A child's `--extension` load re-evaluates this module with
69
+ * `moduleCache: false`, and `/reload` plus a cwd change clear pi's extension
70
+ * cache, so each re-evaluation would start from an empty cache and throw away
71
+ * the parent's warm result — "discover once per process" would degrade to
72
+ * "discover once per module instance", and a child spawned from a re-loaded
73
+ * instance would silently inherit nothing.
74
+ *
75
+ * `Symbol.for` is registry-backed and `globalThis` survives re-evaluation; the
76
+ * same escape hatch is used by `supervisor/child-context.ts`,
77
+ * `supervisor/inprocess-depth.ts` and `supervisor/admission-queue.ts` for the
78
+ * same reason.
79
+ *
80
+ * Do NOT turn this back into a module-level variable, and do not re-assign the
81
+ * holder in `resetExtensionDiscoveryCache` — two module instances would then
82
+ * point at two different holders and the reset would silently stop working.
83
+ */
84
+ const STORE_KEY = Symbol.for("pi-legion.extension-discovery");
68
85
 
69
- /** Reset the cache. Test seam only. */
86
+ interface DiscoveryStore {
87
+ /** Resolved extension list, or undefined until a discovery succeeds. */
88
+ discovered?: string[];
89
+ /** In-flight discovery, so concurrent launches share one `pi list`. */
90
+ inFlight?: Promise<string[]>;
91
+ /**
92
+ * When the last discovery FAILED, in `Date.now()` terms.
93
+ *
94
+ * A failure is still not cached as `[]` (F23: one blip must not disable
95
+ * inheritance until restart), but every caller is not allowed to re-probe
96
+ * either: three warm-up points (router, rpc backend, in-process backend) turn
97
+ * one spawn into two `pi list` calls when each of them retries. This stamp is
98
+ * the short memory that makes the retry storm impossible while keeping the
99
+ * failure recoverable — see `FAILURE_TTL_MS`.
100
+ */
101
+ failedAt?: number;
102
+ }
103
+
104
+ /** Symbol-indexed view of globalThis so the cache survives module re-evaluation. */
105
+ type StoreHolder = { [key: symbol]: DiscoveryStore | undefined };
106
+
107
+ // SAFETY: `globalThis` is an object, so a `Symbol.for`-keyed property on it is
108
+ // readable; the only invariant TypeScript cannot check is that whoever wrote
109
+ // that slot first stored a `DiscoveryStore`. Both sides of that contract live in
110
+ // this file, so the declared slot type is authoritative.
111
+ const store = ((globalThis as unknown as StoreHolder)[STORE_KEY] ??= {});
112
+
113
+ /**
114
+ * Reset the cache. Test seam only.
115
+ *
116
+ * Clears the holder's FIELDS rather than replacing the holder: a replacement
117
+ * would be invisible to every other module instance still pointing at the old
118
+ * object (the exact failure mode the shared holder exists to prevent).
119
+ */
70
120
  export function resetExtensionDiscoveryCache(): void {
71
- discovered = undefined;
72
- discovery = undefined;
121
+ delete store.discovered;
122
+ delete store.inFlight;
123
+ // The failure stamp is part of the cache: leaving it behind would make the
124
+ // next case inherit this one's backoff and silently skip its `pi list`.
125
+ delete store.failedAt;
126
+ }
127
+
128
+ /**
129
+ * Expand one declared `pi.extensions` entry into loadable files (#280 R1-a).
130
+ *
131
+ * A manifest may name a DIRECTORY (`pi.extensions: ["./extensions"]` — that is
132
+ * what `pi-diff-review` ships). Handing that path to `pi -e` is fatal: pi's
133
+ * loader requires an entry point and exits 1 with `Failed to load extension:
134
+ * Cannot find module`. So a directory is resolved the way pi's own
135
+ * `discoverAndLoadExtensions` resolves an explicitly configured path: the
136
+ * directory's own `pi.extensions`, else its `index.ts`/`index.js`, else the
137
+ * `*.ts`/`*.js` files sitting directly in it.
138
+ *
139
+ * `allowNestedManifest` bounds the walk to one extra level: a nested package's
140
+ * manifest is read, but the directories IT declares expand to their files
141
+ * instead of recursing again. Without that bound a manifest declaring `"./"`
142
+ * would re-enter itself forever.
143
+ *
144
+ * Returns `[]` when nothing loadable is inside, so the caller can DROP the entry
145
+ * rather than emit a path that cannot be loaded.
146
+ */
147
+ function expandDeclaredEntry(entry: string, allowNestedManifest: boolean): string[] {
148
+ let stat: fs.Stats;
149
+ try {
150
+ stat = fs.statSync(entry);
151
+ } catch {
152
+ return [];
153
+ }
154
+ if (stat.isFile()) return [entry];
155
+ if (!stat.isDirectory()) return [];
156
+ if (allowNestedManifest) {
157
+ const nested = resolveExtensionEntries(entry, false);
158
+ if (nested.length > 0) return nested;
159
+ }
160
+ const index = indexEntryIn(entry);
161
+ if (index) return [index];
162
+ // The sweep inherits the same depth budget: a declared directory reached from
163
+ // inside a nested resolution may list its own files, but must not descend into
164
+ // subdirectories again (otherwise a manifest declaring `"./"` deep in the tree
165
+ // would walk without a bound).
166
+ return discoverExtensionFiles(entry, allowNestedManifest);
167
+ }
168
+
169
+ /** `<dir>/index.ts`, else `<dir>/index.js`, else undefined. */
170
+ function indexEntryIn(dir: string): string | undefined {
171
+ for (const candidate of ["index.ts", "index.js"]) {
172
+ const entry = path.join(dir, candidate);
173
+ if (fs.existsSync(entry)) return entry;
174
+ }
175
+ return undefined;
176
+ }
177
+
178
+ /**
179
+ * Sweep a declared directory the way pi's `collectAutoExtensionEntries` does.
180
+ *
181
+ * Same shape as pi, because anything less silently loses extensions:
182
+ *
183
+ * - `.`-prefixed entries and `node_modules` are skipped (pi skips both; a
184
+ * dotfile such as `.eslintrc.js` is never an entry point)
185
+ * - a `*.ts`/`*.js` file is collected as-is
186
+ * - a subdirectory is resolved as a package of its own, which is what makes
187
+ * the documented `extensions/<name>/index.ts` layout loadable at all; a
188
+ * subdirectory that resolves to nothing contributes nothing
189
+ *
190
+ * `allowNestedDirs` is the depth bound. pi stops after one level
191
+ * (`collectAutoExtensionEntries` → `resolveExtensionEntries` → no further), and
192
+ * so do we: the top-level sweep may resolve subdirectories, but a directory
193
+ * reached from inside a nested resolution sweeps files only. That keeps a
194
+ * mistyped or self-declaring path from becoming an unbounded walk.
195
+ *
196
+ * Sorted, so the child's argv is stable between runs and platforms.
197
+ */
198
+ function discoverExtensionFiles(dir: string, allowNestedDirs: boolean): string[] {
199
+ let entries: fs.Dirent[];
200
+ try {
201
+ entries = fs.readdirSync(dir, { withFileTypes: true });
202
+ } catch {
203
+ return [];
204
+ }
205
+ const files: string[] = [];
206
+ for (const entry of entries) {
207
+ if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
208
+ const full = path.join(dir, entry.name);
209
+ let isFile = entry.isFile();
210
+ let isDir = entry.isDirectory();
211
+ if (entry.isSymbolicLink()) {
212
+ try {
213
+ const stats = fs.statSync(full);
214
+ isFile = stats.isFile();
215
+ isDir = stats.isDirectory();
216
+ } catch {
217
+ continue;
218
+ }
219
+ }
220
+ if (isFile) {
221
+ if (/\.(ts|js)$/.test(entry.name)) files.push(full);
222
+ continue;
223
+ }
224
+ if (isDir && allowNestedDirs) {
225
+ // `allowNestedManifest: false` is the gate: the nested resolution sweeps
226
+ // files but never descends into subdirectories itself.
227
+ files.push(...resolveExtensionEntries(full, false));
228
+ }
229
+ }
230
+ return files.sort();
73
231
  }
74
232
 
75
233
  /**
76
234
  * Resolve a package directory to its extension entry points, exactly like pi's
77
235
  * own loader: `package.json`'s `pi.extensions` first, then `index.ts`/`index.js`.
78
236
  *
237
+ * Declared entries may themselves be directories (R1-a); those expand into
238
+ * loadable files, and an entry that expands to nothing is dropped so the caller
239
+ * never sees a path `pi -e` would refuse. Every returned path is a file.
240
+ *
79
241
  * A directory with neither is not an extension package (a theme pack, a skill
80
242
  * bundle) and contributes nothing.
81
243
  */
82
- export function resolveExtensionEntries(dir: string): string[] {
244
+ export function resolveExtensionEntries(dir: string, allowNestedManifest = true): string[] {
83
245
  const manifestPath = path.join(dir, "package.json");
84
246
  try {
85
247
  if (fs.existsSync(manifestPath)) {
@@ -88,21 +250,27 @@ export function resolveExtensionEntries(dir: string): string[] {
88
250
  };
89
251
  const declared = manifest.pi?.extensions;
90
252
  if (Array.isArray(declared)) {
91
- const resolved = declared
92
- .filter((entry): entry is string => typeof entry === "string" && entry.length > 0)
93
- .map((entry) => path.resolve(dir, entry))
94
- .filter((entry) => fs.existsSync(entry));
253
+ // R1-a: directories expand; empty expansions are dropped. Only files
254
+ // reach the caller, because that is all `-e` can load. Deduped because a
255
+ // manifest may name both a directory and a file inside it.
256
+ const resolved: string[] = [];
257
+ const seen = new Set<string>();
258
+ for (const entry of declared) {
259
+ if (typeof entry !== "string" || entry.length === 0) continue;
260
+ for (const file of expandDeclaredEntry(path.resolve(dir, entry), allowNestedManifest)) {
261
+ if (seen.has(file)) continue;
262
+ seen.add(file);
263
+ resolved.push(file);
264
+ }
265
+ }
95
266
  if (resolved.length > 0) return resolved;
96
267
  }
97
268
  }
98
269
  } catch {
99
270
  // Unreadable/invalid manifest: fall through to the index-file convention.
100
271
  }
101
- for (const candidate of ["index.ts", "index.js"]) {
102
- const entry = path.join(dir, candidate);
103
- if (fs.existsSync(entry)) return [entry];
104
- }
105
- return [];
272
+ const index = indexEntryIn(dir);
273
+ return index ? [index] : [];
106
274
  }
107
275
 
108
276
  /** Parse `pi list` output into the package directories it reports. */
@@ -119,42 +287,117 @@ export function parsePiList(output: string): string[] {
119
287
  }
120
288
 
121
289
  /**
122
- * Ask pi which packages are installed and resolve them to extension files.
290
+ * Run one `pi list` and resolve it to extension entry paths.
123
291
  *
124
- * Never throws: a host without `pi` on PATH, or a `pi list` failure, degrades to
125
- * "no discovered extensions" (children still get their explicit list plus the
126
- * child-guard). Failure to discover must not fail a launch.
292
+ * Returns `undefined` when discovery itself failed (no `pi` on PATH, non-zero
293
+ * exit, timeout) so the caller can distinguish "nothing installed" from "could
294
+ * not ask" — only the latter must stay retryable.
127
295
  */
128
- export async function discoverHostExtensions(env: NodeJS.ProcessEnv = process.env): Promise<string[]> {
129
- if (discovered) return discovered;
130
- discovery ??= (async () => {
131
- let dirs: string[] = [];
132
- try {
133
- const { stdout } = await execFileAsync("pi", ["list"], {
134
- env,
135
- timeout: 15_000,
136
- maxBuffer: 4 * 1024 * 1024,
137
- });
138
- dirs = parsePiList(stdout);
139
- } catch {
140
- dirs = [];
141
- }
142
- const entries = dirs.flatMap((dir) => resolveExtensionEntries(dir));
143
- const seen = new Set<string>();
144
- discovered = entries.filter((entry) => {
145
- const key = path.resolve(entry);
146
- if (seen.has(key)) return false;
147
- seen.add(key);
148
- return true;
296
+ async function resolveHostExtensions(env: NodeJS.ProcessEnv): Promise<string[] | undefined> {
297
+ let dirs: string[];
298
+ try {
299
+ const { stdout } = await execFileAsync("pi", ["list"], {
300
+ env,
301
+ timeout: 15_000,
302
+ maxBuffer: 4 * 1024 * 1024,
149
303
  });
150
- return discovered;
151
- })();
152
- return discovery;
304
+ dirs = parsePiList(stdout);
305
+ } catch {
306
+ return undefined;
307
+ }
308
+ const entries = dirs.flatMap((dir) => resolveExtensionEntries(dir));
309
+ const seen = new Set<string>();
310
+ return entries.filter((entry) => {
311
+ const key = path.resolve(entry);
312
+ if (seen.has(key)) return false;
313
+ seen.add(key);
314
+ return true;
315
+ });
316
+ }
317
+
318
+ /**
319
+ * How long a FAILED discovery is remembered, in milliseconds.
320
+ *
321
+ * A failure cannot be cached as `[]` (F23), but it cannot be re-probed by every
322
+ * caller either: three warm-up points sit on one spawn (router + rpc backend +
323
+ * in-process backend), so an always-failing host pays twice per spawn today, and
324
+ * `pi list` costs 0.3–0.9 s measured (up to its 15 s timeout in the worst case) —
325
+ * before the memory gate even runs.
326
+ *
327
+ * 5 s covers every warm call of the same spawn with a wide margin (they are
328
+ * tens of milliseconds apart at most) while staying short enough that a
329
+ * transient failure costs a bounded window, not the process lifetime. Measured
330
+ * `pi list` latency (0.3–0.9 s) makes 5 s roughly one order of magnitude above a
331
+ * single probe, i.e. a retry is never more than a few spawns away.
332
+ */
333
+ export const FAILURE_TTL_MS = 5_000;
334
+
335
+ /**
336
+ * Discover the host's extensions once per process and return them.
337
+ *
338
+ * Idempotent and concurrency-safe: the first caller runs `pi list`, concurrent
339
+ * callers await the same in-flight promise, and once resolved every later call
340
+ * returns the cache without spawning anything.
341
+ *
342
+ * Never throws and never rejects: a host without `pi`, or a failing `pi list`,
343
+ * degrades to `[]` (children still get their explicit list plus the
344
+ * child-guard). Failure to discover must not fail a launch.
345
+ *
346
+ * A FAILED discovery is deliberately not cached as `[]`. The previous shape
347
+ * cached `[]` permanently, so one transient `pi list` error disabled inheritance
348
+ * until the process restarted. Instead the failure stamps `failedAt` and calls
349
+ * inside `FAILURE_TTL_MS` short-circuit to `[]` without a probe — the backoff
350
+ * that keeps the three warm-up points idempotent (R2-b). Once the stamp ages out,
351
+ * the next call probes again, so a failure is never a permanent off switch (F23).
352
+ *
353
+ * `env` is handed to `execFile` (a caller can point discovery at a specific
354
+ * PATH); the cache itself is process-wide and does not vary by env.
355
+ */
356
+ export async function ensureHostExtensions(env: NodeJS.ProcessEnv = process.env): Promise<string[]> {
357
+ if (store.discovered) return store.discovered;
358
+ if (store.failedAt !== undefined && Date.now() - store.failedAt < FAILURE_TTL_MS) return [];
359
+ if (!store.inFlight) {
360
+ store.inFlight = (async () => {
361
+ try {
362
+ const resolved = await resolveHostExtensions(env);
363
+ if (!resolved) {
364
+ store.failedAt = Date.now();
365
+ return [];
366
+ }
367
+ store.discovered = resolved;
368
+ // A success supersedes any earlier failure, so the backoff stamp must not
369
+ // outlive it — otherwise a later re-probe (after a reset) would inherit an
370
+ // unrelated window.
371
+ delete store.failedAt;
372
+ return resolved;
373
+ } catch {
374
+ // Belt and braces: the contract is "never rejects", and a launch must
375
+ // never fail because the host could not be asked about packages.
376
+ store.failedAt = Date.now();
377
+ return [];
378
+ } finally {
379
+ // Success caches through `discovered`; failure leaves the `failedAt`
380
+ // stamp behind (the backoff) plus a cleared in-flight slot, or the
381
+ // retry could never start.
382
+ delete store.inFlight;
383
+ }
384
+ })();
385
+ }
386
+ return store.inFlight;
153
387
  }
154
388
 
389
+ /**
390
+ * Historical name for the same resolver; kept for callers that still use it.
391
+ *
392
+ * Aliased rather than wrapped so both names are literally one function: a
393
+ * second `async` wrapper would only add a promise layer and a second identity
394
+ * for the same cache.
395
+ */
396
+ export const discoverHostExtensions = ensureHostExtensions;
397
+
155
398
  /** Synchronous read of whatever discovery has already produced. */
156
399
  export function discoveredHostExtensions(): string[] | undefined {
157
- return discovered;
400
+ return store.discovered;
158
401
  }
159
402
 
160
403
  /**
@@ -17,6 +17,7 @@ import {
17
17
  childInheritsExtensions,
18
18
  collectChildExtensions,
19
19
  } from "../runs/args.ts";
20
+ import { ensureHostExtensions } from "../runs/extension-inheritance.ts";
20
21
  import {
21
22
  ErrorCodes,
22
23
  unknownChildError,
@@ -705,6 +706,20 @@ export class InProcessSupervisor implements LegionSupervisor {
705
706
  }
706
707
 
707
708
  async spawnChild(input: SpawnInput): Promise<ChildHandle> {
709
+ // #280 R1-b: warm host-extension discovery HERE, not only in
710
+ // `RoutingSupervisor`, because a caller may construct this backend
711
+ // directly (`new Orchestrator({ supervisor: new RpcSupervisor() })` is the
712
+ // documented public shape in `api.ts`, and it is `Orchestrator`'s default).
713
+ // That path bypasses the router entirely, and without this the child's
714
+ // argv builder would read an empty sync cache and inherit nothing.
715
+ //
716
+ // It cannot live in `Orchestrator.launch`: that fast path must not gain an
717
+ // await before the synchronous admission block
718
+ // (launch-admission-queue-v1 §13.7 ban 5 / F16).
719
+ //
720
+ // Idempotent and never rejects, so the router's own warm call makes this a
721
+ // map lookup, and a failed `pi list` still leaves a working launch.
722
+ await ensureHostExtensions();
708
723
  if (input.agent.kind !== "pi") {
709
724
  throw new SubagentError(
710
725
  `InProcessSupervisor only supports pi children (received kind '${input.agent.kind}')`,
@@ -16,6 +16,7 @@ import {
16
16
  import { InProcessSupervisor, type InProcessSlotEvent } from "./inprocess-supervisor.ts";
17
17
  import { RpcSupervisor } from "./rpc-supervisor.ts";
18
18
  import { resolveSpawnMode } from "./spawn-mode.ts";
19
+ import { ensureHostExtensions } from "../runs/extension-inheritance.ts";
19
20
  import type { UIProxy } from "./ui-proxy.ts";
20
21
  import type {
21
22
  ChildHandle,
@@ -127,6 +128,19 @@ export class RoutingSupervisor implements LegionSupervisor {
127
128
 
128
129
  async spawnChild(input: SpawnInput): Promise<ChildHandle> {
129
130
  const mode = resolveSpawnMode(input.agent);
131
+ // #280: warm host-extension discovery before anything is spawned.
132
+ //
133
+ // This is the one place that sees BOTH backends and sits on an await chain
134
+ // already, so every spawn path is covered — the tool execute frame, the
135
+ // postman timer, the team-restore microtask and the launch-queue pump all
136
+ // funnel through `Orchestrator.launch` → here. It deliberately does NOT live
137
+ // in `launch()`: that fast path must not gain an await before the
138
+ // synchronous admission block (launch-admission-queue-v1 §13.7 ban 5).
139
+ //
140
+ // `ensureHostExtensions` is idempotent and never rejects, so a warm cache is
141
+ // a map lookup and a failed `pi list` leaves the child with the explicit
142
+ // list plus the guard instead of failing the launch.
143
+ await ensureHostExtensions();
130
144
  if (mode === "inprocess") {
131
145
  if (input.agent.kind !== "pi") {
132
146
  throw new SubagentError(
@@ -11,6 +11,7 @@ import * as path from "node:path";
11
11
  import { fileURLToPath } from "node:url";
12
12
  import { formatChildTask } from "../extension/child-guard.ts";
13
13
  import { buildPiArgs, LEGION_IDENTITY_ENV_KEYS } from "../runs/args.ts";
14
+ import { ensureHostExtensions } from "../runs/extension-inheritance.ts";
14
15
  import { TEMP_OWNER_FILE, writeOwnerFile } from "../runs/owner.ts";
15
16
  import { checkMemoryGate, childMemoryEnv } from "./child-memory.ts";
16
17
  import { enrichFailureWithAutoDoctor } from "../extension/auto-doctor.ts";
@@ -228,6 +229,19 @@ export class RpcSupervisor implements LegionSupervisor {
228
229
  }
229
230
 
230
231
  async spawnChild(input: SpawnInput): Promise<ChildHandle> {
232
+ // #280 R1-b: warm host-extension discovery HERE, not only in
233
+ // `RoutingSupervisor`. `api.ts` documents `new Orchestrator({ supervisor:
234
+ // new RpcSupervisor(), ... })` and that is also `Orchestrator`'s default,
235
+ // so this backend is reachable without ever passing through the router —
236
+ // without this warm-up the child's argv builder reads an empty sync cache
237
+ // and the whole of #280 stays broken on that public path.
238
+ //
239
+ // It deliberately precedes the memory gate: warming is cheap, idempotent
240
+ // and never rejects, and doing it first keeps the ordering identical for
241
+ // admitted and refused spawns. It must NOT move into `Orchestrator.launch`
242
+ // (launch-admission-queue-v1 §13.7 ban 5 / F16: that fast path may not gain
243
+ // an await before the synchronous admission block).
244
+ await ensureHostExtensions();
231
245
  // Memory gate (program level): refuse before any process/temp dir exists.
232
246
  const inFlight = [...this.children.values()].filter((child) => child.state === "starting").length;
233
247
  const gate = checkMemoryGate({ name: input.name, inFlight, ...(this.memoryAvailable ? { available: this.memoryAvailable } : {}) });