@epoch-agent/infra 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -253,6 +253,42 @@ declare function projectHooksPath(projectRoot: string): string;
253
253
  * 「哪些操作不用问就放行」。判定在 `core/policy/sources.ts`。
254
254
  */
255
255
  declare function projectPoliciesDir(projectRoot: string): string;
256
+ /**
257
+ * Claude Code 的配置目录名。**不是我们的目录**,我们只读、从不写
258
+ * (方案 50 §七:往别人的配置文件里写东西是越界)。
259
+ */
260
+ declare const CLAUDE_DIR_NAME = ".claude";
261
+ /**
262
+ * Claude Code 的用户级设置(`~/.claude/settings.json`)。
263
+ *
264
+ * ⚠️ **默认值刻意是 `homedir()` 而不是 {@link resolveHomeDir}。** 这一份不是我们的
265
+ * 数据目录里的文件,是**另一个产品**按操作系统用户放的文件 —— `EPOCH_HOME` /
266
+ * `EPOCH_PROFILE` 把我们自己的家目录搬到哪儿,都不影响 Claude Code 去哪儿读它。
267
+ * 跟着 `resolveHomeDir()` 走的话,`EPOCH_PROFILE=work` 之下我们会去
268
+ * `~/.epoch/profiles/work/.claude/settings.json` 找一份**永远不存在**的文件,
269
+ * 于是「桥接生效了吗」的答案会随 profile 静默翻转。
270
+ *
271
+ * 参数留着是为了用例能指到临时目录 —— 同本文件其他函数的口径。
272
+ */
273
+ declare function claudeUserSettingsPath(home?: string): string;
274
+ /**
275
+ * Claude Code 的项目级设置(`<项目根>/.claude/settings.json`),**提交进 git 的那份**。
276
+ *
277
+ * ⚠️ 它和 {@link projectHooksPath} **同一档闸门**:未信任 / 未决定一律连读都不读。
278
+ * 风险等级逐字相同 —— 都是「clone 下来一个仓库就能让我们 spawn 它写的命令」,
279
+ * 而这一份还更容易被忽略:仓库里有 `.claude/settings.json` 是今天的常态,
280
+ * 提交它的人当时想的是「给 Claude Code 用」,压根没想过第二个工具会去读。
281
+ * 判定在 `core/hook/sources.ts`。
282
+ */
283
+ declare function projectClaudeSettingsPath(projectRoot: string): string;
284
+ /**
285
+ * Claude Code 的项目本地设置(`<项目根>/.claude/settings.local.json`)。
286
+ *
287
+ * 按 Claude Code 的约定这份是本机文件、该被 `.gitignore` 掉,但**我们不检查这一点**:
288
+ * 它在不在 git 里都不改变风险 —— 过的是同一道信任闸门,而闸门问的是
289
+ * 「你信不信这个目录」,不是「这个文件有没有被提交」。
290
+ */
291
+ declare function projectClaudeLocalSettingsPath(projectRoot: string): string;
256
292
  /**
257
293
  * 企业托管设置(方案 22 §2.6 的第 ∞ 层)。**由 IT 管理员分发,用户不该能改**。
258
294
  *
@@ -846,7 +882,7 @@ interface RipgrepResolution {
846
882
  * 我们没为 Linux 出 vendor 包,所以 Linux 用户命中的正是 `missing` 这一档,
847
883
  * 那条文案对他们是**唯一**的指引,删掉等于让他们没法自救。
848
884
  */
849
- declare const RIPGREP_INSTALL_HINT: string;
885
+ declare function ripgrepInstallHint(): string;
850
886
  /** 测试用的注入点。生产调用一律不传参,走 memoize 那条路 */
851
887
  interface ResolveRipgrepOptions {
852
888
  env?: NodeJS.ProcessEnv;
@@ -937,10 +973,66 @@ interface KillProcessTreeOptions extends KillSignalOptions {
937
973
  * 因为「后代自己又 detach 出去」的情况下它已经不在原来那个组里了。
938
974
  */
939
975
  declare function killProcessTree(options: KillProcessTreeOptions): Promise<void>;
976
+ /**
977
+ * 一个**连同身份一起记下来**的 pid。
978
+ *
979
+ * `born` 是这个进程的创建时刻(同一份快照内部可比,格式由采集后端决定)。
980
+ * 取不到就是空串 —— 那时视为「认不出身份」,校验一律放过(见 {@link killStampedPids})。
981
+ */
982
+ interface StampedPid {
983
+ pid: number;
984
+ born: string;
985
+ }
986
+ /**
987
+ * 同 {@link collectProcessTree},但**连创建时刻一起交出来**。
988
+ *
989
+ * ## 它是给「先收集、隔一会儿再杀」那条路的
990
+ *
991
+ * 那条路的形状是(`plugin-mcp` 的 stdio transport、`plugin-lsp` 的 dispose):
992
+ *
993
+ * ```ts
994
+ * const orphans = await collectProcessTreeStamped(pid); // ppid 链还在,先拍
995
+ * await transport.close(); // 优雅关闭,可能要几秒
996
+ * await killStampedPids(orphans, { escalate: true }); // 再收拾被 init 收养的
997
+ * ```
998
+ *
999
+ * **中间那一步是几秒,而 pid 在这几秒里可能被系统回收给别人**(MCP SDK 的
1000
+ * `close()` 是 stdin EOF → 等 2s → SIGTERM → 等 2s → SIGKILL)。拿裸 pid 去杀
1001
+ * 等于赌「这几秒里没人拿到这个号」—— 2026-08-22 实测那个赌是会输的,
1002
+ * 判据和实测数字在 {@link windowsSnapshot} 的 JSDoc 上。
1003
+ *
1004
+ * POSIX 上 `born` 是空串:`pgrep -P` 给不出创建时刻,而为它单独跑一轮 `ps` 会把
1005
+ * 「收集」变成两次采集。**那一侧因此保持今天的行为**(不校验),
1006
+ * 这条差异如实写在这儿而不是假装两个平台一样。
1007
+ */
1008
+ declare function collectProcessTreeStamped(rootPid: number, platform?: NodeJS.Platform): Promise<StampedPid[]>;
1009
+ /**
1010
+ * 收一组**之前**收集到的 pid —— 先确认它们还是原来那些进程,再动手。
1011
+ *
1012
+ * 和 {@link killPids} 的差别只有一件事:动手之前**重新拍一张快照**,把创建时刻
1013
+ * 变了(或者已经不在快照里)的那些**剔掉**。理由见 {@link collectProcessTreeStamped}
1014
+ * ——「几秒前那个 pid 是我们的」和「现在那个 pid 还是我们的」是两句话,
1015
+ * 而在 Windows 上后者经常不成立。
1016
+ *
1017
+ * 三种放过的情形,每一种都是刻意的:
1018
+ *
1019
+ * | 情形 | 怎么办 | 为什么 |
1020
+ * | ------------------------ | ---------------- | ---------------------------------------------------------- |
1021
+ * | 新快照采不到(两条路都空) | **照旧全杀** | 判不出来时保持修复前的行为,别因为「采集失败」静默漏收残留 |
1022
+ * | `born` 是空串(POSIX) | **照旧杀** | 那一侧本来就没有创建时刻,见上 |
1023
+ * | pid 不在新快照里 | 跳过 | 它已经没了,杀它只可能打到别人 |
1024
+ */
1025
+ declare function killStampedPids(stamped: readonly StampedPid[], options?: KillSignalOptions, platform?: NodeJS.Platform): Promise<void>;
940
1026
  /** {@link killPids} 的额外目标:进程组、PTY 句柄 */
941
1027
  interface ExtraTargets {
942
1028
  /** 传了就额外发一发 `kill(-pid)`。**只有确实 detach 过的 pid 才能传** */
943
1029
  groupLeaderPid?: number;
1030
+ /**
1031
+ * 同时要收掉的 PTY 句柄。**只许 `kill()` 一次** —— 第二发会把 Windows 宿主
1032
+ * 进程带走(原生崩,判据在 plugin-terminal 的 `killPtyTree` 那张 WeakMap 上)。
1033
+ * 所以它跟首发那一轮走,`escalate` 的 SIGKILL 补刀轮**不碰它**;它那个 pid 的
1034
+ * 补刀由 targets 里的常规清扫负责(唯一的调用方传的正是 `[...后代, pty.pid]`)。
1035
+ */
944
1036
  pty?: KillablePty;
945
1037
  }
946
1038
  /**
@@ -959,6 +1051,9 @@ interface ExtraTargets {
959
1051
  *
960
1052
  * 传进来的顺序就是发信号的顺序,调用方自己负责「叶子在前」
961
1053
  * ({@link collectProcessTree} 的返回值已经是这个顺序)。
1054
+ *
1055
+ * `extra.pty` 例外:它**只跟首发那一轮收一次**(判据见 {@link ExtraTargets}),
1056
+ * 不参与 `escalate` 的 SIGKILL 补刀。
962
1057
  */
963
1058
  declare function killPids(pids: readonly number[], options?: KillSignalOptions, extra?: ExtraTargets): Promise<void>;
964
1059
  /**
@@ -1075,16 +1170,33 @@ declare function checkObfuscation(command: string): string | null;
1075
1170
  * 各平台的实现能力:
1076
1171
  * macOS → Seatbelt(sandbox-exec) 已在本机验证
1077
1172
  * Linux → bubblewrap(bwrap) 参数构造有测试,运行时未在本机验证
1078
- * Windows → 无 见下方说明
1079
- *
1080
- * 为什么 Windows 没有:等价能力需要 AppContainer(CreateProcess 时传
1081
- * SECURITY_CAPABILITIES)或 Job Object + AppContainer profile,这些只有
1082
- * Win32 API,Node 里没有绑定,得写 native addon。Job Object 单独用只能限
1083
- * CPU/内存,管不了文件系统和网络——那不构成隔离,所以不假装有。
1084
- * Windows 上 `getIsolation()` 会如实返回 'process'。
1085
- *
1086
- * ⚠️ 上面那段判据**漏了第三条路**(受限令牌 + ACL),调研结论和为什么这一轮
1087
- * 仍然没做,记在 [方案 46 §6.2](../../../../.agents/plans/46-terminal-sandbox-plan.md)。
1173
+ * Windows → 无 **判定不做**,见下方说明
1174
+ *
1175
+ * ## 为什么 Windows 没有:不是做不到,是判定不做(2026-08-22 结案)
1176
+ *
1177
+ * ⚠️ 这一段 2026-08-22 重写过。原文写的是「等价能力需要 AppContainer
1178
+ * Job Object + AppContainer profile,只有 Win32 API,Node 里没有绑定」——
1179
+ * **那段判据漏了第三条路,而且它给人的印象(做不到)是错的**。
1180
+ * 逐条实测(非管理员,Windows 11 Home 26200,取证在
1181
+ * [方案 46 的验收记录第二章](../../../../docs/verify/VERIFY_RECORD-46-terminal-sandbox.md)):
1182
+ *
1183
+ * | 环节 | 实测 |
1184
+ * | ----------------------------------------------- | --------------------------------------- |
1185
+ * | `CreateRestrictedToken`(往 restricting-SIDs 塞私有 SID) | ✅ **非管理员就能** |
1186
+ * | `CreateProcessAsUser` 拿那个令牌起进程 | ✅ **非管理员就能**,`err=0` |
1187
+ * | `icacls` 给那个合成 SID 授权 | ❌ 拒绝:「无任何映射」——只认真实账户 |
1188
+ * | 受限令牌下 `cmd.exe` 真的跑起来 | ❌ `0xC0000142` STATUS_DLL_INIT_FAILED |
1189
+ *
1190
+ * 也就是说**路是通的,但要付的东西比原判据估的多**:除了令牌那几个 API,还要
1191
+ * `SetNamedSecurityInfoW`(合成 SID 没有 CLI 入口)和一整层窗口站 / 桌面 DACL
1192
+ * (最后那一行三种 `lpDesktop` 都一样红,Chromium 就是靠给窗口站授权解决它的)。
1193
+ * 而买到的仍然只是 `partial`:Everyone SID 和硬链接两个洞照旧,**外加 PTY 那条
1194
+ * 路根本递不进令牌**(node-pty 的 `CreateProcess` 在它自己的 native 里)。
1195
+ *
1196
+ * 所以结论是**判定不做**,判据是「买到的东西配不上代价」,不是「做不到」——
1197
+ * 这两句话对下一个来看这里的人是不同的信息。Windows 上 `getIsolation()`
1198
+ * 如实返回 `'process'` / `platform-unsupported`,`epoch doctor` 那一行也明说
1199
+ * 「没有可装的后端,不用去找」(措辞判据在 `cli/src/commands/status.ts`)。
1088
1200
  *
1089
1201
  * ## 为什么这个文件在 infra 而不在 core
1090
1202
  *
@@ -1323,6 +1435,25 @@ interface SandboxPolicy {
1323
1435
  allowNetwork: boolean;
1324
1436
  /** 子进程工作目录(部分后端要显式指定) */
1325
1437
  cwd?: string;
1438
+ /**
1439
+ * 这一次到底要不要上沙箱。**不给 = 要**(没有这个字段之前的行为,逐字节相同)。
1440
+ *
1441
+ * 它是用户那个 `sandbox.terminal` 开关走到这一层的样子(方案 46 §11.3 第一条)。
1442
+ * 为什么是 policy 上的一格、而不是让调用方干脆别调 `confine()`:
1443
+ * **「没包上」是一个要说出口的结论**(见 {@link confine} 的 `@returns`)。
1444
+ * 调用方自己跳过这一层的话,它手里就只有一个 `undefined`,
1445
+ * 而工具输出那一行沙箱说明会整行消失 —— 关掉沙箱之后一个字都不说,
1446
+ * 正是方案 46 §四要修的那个洞的另一种形态。
1447
+ *
1448
+ * ⚠️ **它和 `mode` 是两个轴,别用 `mode: 'danger-full-access'` 代替它。**
1449
+ * `mode` 说的是「这一档承诺什么边界」(由权限档位推出来),这一格说的是
1450
+ * 「用户把这层整个关了」。混成一个的表现是:关掉开关之后
1451
+ * `epoch doctor` 和工具输出都报 `danger-full-access`,于是用户以为自己
1452
+ * 不知什么时候切进了 `bypass` 档 —— 而审批那一半其实一点没松。
1453
+ * 两个轴分开也正是 §2.3「我们不做二维」的**前提**:真要合并成一个维度,
1454
+ * 得先把那节的代价重新算一遍。
1455
+ */
1456
+ enabled?: boolean;
1326
1457
  }
1327
1458
  /**
1328
1459
  * 权限五级 → 沙箱模式。
@@ -1398,15 +1529,20 @@ type Confinement = {
1398
1529
  backend: IsolationBackend;
1399
1530
  mode: SandboxMode;
1400
1531
  /**
1532
+ * `config-disabled` = 用户把 `sandbox.terminal` 那个开关关了;
1401
1533
  * `mode-disabled` = 这一档本来就不上沙箱(`danger-full-access`);
1402
1534
  * `no-backend` = 想上但这台机器上没有后端。
1403
1535
  *
1404
- * 两档必须分开:前者是**用户自己选的**,后者是**平台限制**,
1405
- * 而它们在界面上是两句不同的话(一句「你开了 bypass」,
1406
- * 一句「这个平台没有 OS 级隔离」)。合成一个 `false` 的话,
1407
- * Windows 用户会以为是自己把沙箱关了。
1536
+ * 三档必须分开,它们在界面上是三句不同的话(「你把开关关了」/
1537
+ * 「你开了 bypass」/「这个平台没有 OS 级隔离」)。合成一个 `false`
1538
+ * 的话,Windows 用户会以为是自己把沙箱关了。
1539
+ *
1540
+ * ⚠️ **`config-disabled` 排在 `mode-disabled` 之前**(见 {@link confine}
1541
+ * 里的顺序),理由不是它更重要,是另一句话会变成**可操作的谎**:
1542
+ * 「当前权限档位是允许一切」读起来的下一步动作是「切回 default 就有沙箱了」,
1543
+ * 而开关关着的时候切回去照样没有。
1408
1544
  */
1409
- reason: 'mode-disabled' | 'no-backend';
1545
+ reason: 'config-disabled' | 'mode-disabled' | 'no-backend';
1410
1546
  };
1411
1547
  /**
1412
1548
  * 把一条命令包进当前平台的沙箱。
@@ -1562,12 +1698,325 @@ declare function probeBubblewrap(): string | null;
1562
1698
  */
1563
1699
  declare function buildBwrapArgs(command: string, commandArgs: string[], opts: BubblewrapOptions): string[];
1564
1700
 
1701
+ /**
1702
+ * 后台作业表 —— **kind 无关**(方案 49 PR-3)。
1703
+ *
1704
+ * 「作业」= 一件起来之后还活着、而模型之后还要能**列出来 / 读输出 / 停掉**的事。
1705
+ * 今天有两类,将来有第三类:
1706
+ *
1707
+ * | kind | 谁产出的 | 有「结束」这个状态吗 |
1708
+ * | ----------- | ------------------------------------------ | ----------------------------- |
1709
+ * | `command` | `terminal({ background: true })`(方案 36) | **有** —— 命令会跑完 |
1710
+ * | `shell` | `shell_open` 的持久 PTY 会话(方案 49) | **没有** —— 它一直活着 |
1711
+ * | `agent` | 后台子 agent,**今天没有产出方** | 有 |
1712
+ *
1713
+ * ## 为什么下沉到 infra
1714
+ *
1715
+ * 改造前这张表有**两份**:`plugin-terminal/src/background.ts` 一份(后台命令)、
1716
+ * `plugin-terminal/src/shell/sessions.ts` 一份(持久会话)。两份各自实现了
1717
+ * 环形缓冲、溢出落盘、`since` 游标、按会话分区、「停掉」——
1718
+ * 那不是巧合,是同一件事写了两遍(PR-1 的落地记录里明写着「合出来的那份该住
1719
+ * infra,而 **PR-3 正要把整张作业表下沉到那里**」)。
1720
+ *
1721
+ * 落点选 infra 而不是某个 plugin,判据和 {@link startLongLivedProcess} 下沉时
1722
+ * 逐字同款:**plugin 之间不许互相依赖**,而第三类产出方(后台子 agent)住在
1723
+ * `core/src/delegate` —— core 够得着 infra,够不着 plugin。
1724
+ *
1725
+ * ## 模型只学一套控制方式
1726
+ *
1727
+ * 方案 §三 的主张:「后台跑着的东西不管是什么,都用同一套控制」。于是
1728
+ * `task_list` / `task_output` / `task_wait` / `task_stop` 这四个工具**不认 kind**,
1729
+ * 一个 `s1` 和一个 `t1` 在它们眼里没有区别。工具名保留 `task_*`(不改成 `job_*`)
1730
+ * 的判据在方案 §3.2:那四个名字已经在 `docs/TOOLS.md`、权限规则和审批缓存里了。
1731
+ *
1732
+ * ## 这张表**不负责杀进程活到最后**
1733
+ *
1734
+ * 进程退出时的回收走的是隔壁 [child-process.ts](./child-process.ts) 那张
1735
+ * **按 pid** 记的表 —— 宿主 `dispose()` 调的是 {@link OwnedProcessTable.release}
1736
+ * (收自己那一张,外加引用计数归零时的兜底表)。方案 60 之后**没有「一张全进程的表」
1737
+ * 这回事**了,所以别再写成「`dispose()` 调 `killAllTrackedProcesses()`」:
1738
+ * 那个函数收的是所有表,装进 dispose 就是方案 60 在修的那条 bug。
1739
+ * 两张表的分工不能混:
1740
+ *
1741
+ * | 表 | 键 | 回答的问题 | 谁调它 |
1742
+ * | ------------------------- | ------------ | -------------------------------- | ------------------------------ |
1743
+ * | `child-process.ts` 的表 | pid | 退出时还有什么要收 | 宿主 `dispose()` → `release()` |
1744
+ * | **这一张** | 会话 + 作业 id | 模型手里有什么、怎么读、怎么停 | `task_*` 那四个 |
1745
+ *
1746
+ * 一个 language server(方案 38)在前者里有一格、在这里**没有** —— 模型不该
1747
+ * 在 `task_list` 里看到它。反过来,这里的每一格都必须在前者里也有一格,
1748
+ * 否则退出时收不掉;那条登记由**产出方**负责(有 `sessionId` 的一律走
1749
+ * `processTableFor(sessionId)` 的 `start` / `trackForeign`;`startLongLivedProcess`
1750
+ * / `trackForeignProcess` 那两个自由函数进的是**兜底表**),这张表不代劳。
1751
+ *
1752
+ * ## 按会话分区
1753
+ *
1754
+ * `Map<sessionId, Map<jobId, Job>>`。判据全文在 `plugin-terminal/src/background.ts`
1755
+ * 的文件头(收窄之前一句 `task_stop t1` 能停掉另一个会话的构建),这里不重抄。
1756
+ * **那道墙同时是 `task_stop` 归 `file_read` 的前提** —— 判据写在
1757
+ * `core/src/permission/operation-type.ts` 的 `PROCESS_TABLE_TOOLS` 上。
1758
+ */
1759
+ /**
1760
+ * 作业种类。
1761
+ *
1762
+ * `agent` **今天没有任何产出方** —— 方案 §六 那条「下沉时把接口留出来,不实现」
1763
+ * 指的就是它。留在这儿而不是等真做后台子 agent 时再加,理由是这个联合类型
1764
+ * 一旦被 `switch` 消费(`describeJob` 的前缀、`task_wait` 的分档),
1765
+ * 补一档就会在编译期报出每一处该跟着改的地方;而缺这一档时它们是静默的。
1766
+ */
1767
+ type JobKind = 'command' | 'shell' | 'agent';
1768
+ /**
1769
+ * 作业状态。**六档是两族的并集,不是新发明的**:
1770
+ *
1771
+ * - `running` / `exited` / `failed` / `killed` —— 后台命令那四档(`BackgroundTaskStatus`)
1772
+ * - `running` / `exited` / `closed` / `reaped` —— 持久会话那四档(`ShellStatus`)
1773
+ *
1774
+ * 合并时**没有把 `killed` 和 `closed` 并成一档**,虽然两者都是「我们停的」。
1775
+ * 判据是产出方各自的措辞已经发出去了:后台任务说「已停止」,持久会话说
1776
+ * 「已关闭」,而模型下一步的动作不同(前者的输出还值得取,后者的环境没了)。
1777
+ * 哪一档由产出方在 {@link JobSpec.stoppedStatus} 上说。
1778
+ */
1779
+ type JobStatus = 'running' | 'exited' | 'failed' | 'killed' | 'closed' | 'reaped';
1780
+ /** 交给工具层 / 展示层的那一份。**不含任何进程句柄** */
1781
+ interface JobInfo {
1782
+ /** 短 id,模型要在对话里引用它(`t1` / `s1`),所以不是 uuid */
1783
+ id: string;
1784
+ kind: JobKind;
1785
+ /**
1786
+ * 一行标签,**各 kind 各自的「这是什么」**:命令行 / 持久会话的工作目录 /
1787
+ * 子 agent 的目标。合成一个字段而不是三个可选字段,是因为消费方
1788
+ * (`task_list` 那一行、doctor 那一节)要的正是「一行说清楚它是什么」。
1789
+ */
1790
+ label: string;
1791
+ cwd?: string;
1792
+ /** 进程 pid。起之前和起不来时是 0 */
1793
+ pid: number;
1794
+ status: JobStatus;
1795
+ /** `exited` 时才有意义 */
1796
+ exitCode?: number;
1797
+ startedAt: number;
1798
+ /** 不在跑了才有 */
1799
+ endedAt?: number;
1800
+ /** 上次发过输入或读过输出的时刻 —— 持久会话的空闲回收量的就是它 */
1801
+ lastActivity: number;
1802
+ /** 累计产出过多少字节(含已经滚出环形缓冲的) */
1803
+ outputBytes: number;
1804
+ /** 有没有滚出去过 */
1805
+ truncated: boolean;
1806
+ /** 全文落盘的位置(只有溢出过才有) */
1807
+ artifact?: string;
1808
+ }
1809
+ /** 登记一格作业要说清楚的东西 */
1810
+ interface JobSpec<D = unknown> {
1811
+ /** 归哪个会话(`ToolContext.sessionId`)。**必填**,判据见文件头 */
1812
+ sessionId: string;
1813
+ kind: JobKind;
1814
+ /** 见 {@link JobInfo.label} */
1815
+ label: string;
1816
+ cwd?: string;
1817
+ /** 环形缓冲留多少字节。各 kind 取值可以不同(后台命令和持久会话今天都是 256KB) */
1818
+ maxOutput: number;
1819
+ /** 溢出的全文落进哪个目录(`artifactsDir(sessionId, homeDir)` 的产物) */
1820
+ artifactsDir: string;
1821
+ /** artifact 文件名主干里那一段前缀(`task` / `shell`),只影响文件名 */
1822
+ artifactLabel: string;
1823
+ /**
1824
+ * 怎么收它。**由产出方给**,判据同 {@link trackForeignProcess} 的 `kill`:
1825
+ * 后台命令收的是进程树,持久会话收的是 pty(forkpty / ConPTY 那侧另有资源),
1826
+ * 将来的子 agent 收的是一个 `AbortController` —— 让这一层去猜等于把产出方的
1827
+ * 知识抄进 infra。
1828
+ */
1829
+ stop: () => Promise<void>;
1830
+ /** 被 {@link stopJob} 收掉之后记成什么状态。缺省 `killed` */
1831
+ stoppedStatus?: JobStatus;
1832
+ /**
1833
+ * 这类作业有没有「结束」这个状态。
1834
+ *
1835
+ * `false` 的唯一一族是持久会话:它一直活着,`task_wait s1` 等的是一件不会
1836
+ * 发生的事。工具层据此**立刻**回一句实话而不是把整轮卡满超时
1837
+ * (方案 §五 那条「不做 `shell_wait`」说的是同一件事)。
1838
+ */
1839
+ terminates: boolean;
1840
+ /**
1841
+ * 产出方自己的东西(沙箱结论、`IPty` 句柄……)。**这一层一眼都不看它**。
1842
+ *
1843
+ * 有这个槽而不是让产出方另开一张 `Map<jobId, Extra>`,判据和
1844
+ * {@link TrackedEntry} 那条「为什么不让持久 PTY 另起一张表」同款:
1845
+ * 多一张按同一个键分区的表,就多一处「作业没了而那张表忘了摘」的机会。
1846
+ */
1847
+ detail: D;
1848
+ }
1849
+ /**
1850
+ * 产出方手里的那个把手。
1851
+ *
1852
+ * 起进程那一刻拿到,之后每一块输出走它 —— 那是最热的一条路,走一遍
1853
+ * 「按会话查表 + 按 id 查表」不值得。查表版的入口另有一套
1854
+ * ({@link appendJob} / {@link touchJob}),给「手里只有 id」的调用点用。
1855
+ */
1856
+ interface JobHandle<D = unknown> {
1857
+ readonly id: string;
1858
+ readonly detail: D;
1859
+ /** 当下的样子 */
1860
+ info(): JobInfo;
1861
+ /** 当下的状态。产出方判「这次退出是不是我们干的」用 */
1862
+ status(): JobStatus;
1863
+ /** 进程起来之后补上 pid(`startLongLivedProcess` 是同步返回的,但记录建得更早) */
1864
+ setPid(pid: number): void;
1865
+ /** 喂一段输出:环形缓冲 + 溢出落盘 */
1866
+ append(chunk: string): void;
1867
+ /** 记一次活动 */
1868
+ touch(): void;
1869
+ /** 现在攒了多少字节 —— 产出方取游标用(`shell_send` 要「这一句引出来的输出」) */
1870
+ cursor(): number;
1871
+ /**
1872
+ * 它**自己**结束了。已经不在 `running` 的调用是空操作 ——
1873
+ * 被 `task_stop` 杀掉的进程也会走 exit,而「是我们停的」和「它自己结束的」
1874
+ * 对模型是两件事。
1875
+ */
1876
+ finish(status: 'exited' | 'failed', exitCode?: number): void;
1877
+ }
1878
+ /**
1879
+ * 登记一格作业。
1880
+ *
1881
+ * 起进程这件事仍然归产出方 —— 这里只收下「它是什么、怎么读、怎么停」。
1882
+ */
1883
+ declare function registerJob<D>(spec: JobSpec<D>): JobHandle<D>;
1884
+ /**
1885
+ * 这个会话起过的作业,**只有这个会话的**。
1886
+ *
1887
+ * 用 `get` 不用 {@link bucketOf}:这条被读得很勤(检视面板每次回合状态变化都拉
1888
+ * 一次),现开一格的话,只要有人打开过面板就在这张表里留一个空 Map。
1889
+ *
1890
+ * @param kind 只要这一类。**不给 = 全要**,那正是 `task_list` 走的那条路
1891
+ * (方案 §四 验收 11:同时列出后台命令和持久会话)
1892
+ */
1893
+ declare function listJobs(sessionId: string, kind?: JobKind): JobInfo[];
1894
+ /** 一格作业当下的样子。别的会话的 id 一律 `undefined`(见 {@link jobIn}) */
1895
+ declare function getJob(sessionId: string, id: string): JobInfo | undefined;
1896
+ /**
1897
+ * 产出方寄存在这一格上的东西({@link JobSpec.detail})。
1898
+ *
1899
+ * `D` 由调用方指定:**这一层不知道也不检查它是什么**,所以这是一次断言而不是
1900
+ * 一次校验。安全的原因是产出方唯一:一个 `kind` 的 detail 只有起它的那个模块
1901
+ * 写过、也只有它读。跨 kind 读之前先看 {@link getJob} 的 `kind`。
1902
+ */
1903
+ declare function jobDetail<D>(sessionId: string, id: string): D | undefined;
1904
+ /**
1905
+ * 全部会话的作业。给两个消费者用:持久会话的空闲回收(要跨会话扫),
1906
+ * 以及 `epoch doctor` 那一节(要报「这个进程里还有什么活着」)。
1907
+ *
1908
+ * ⚠️ **不给工具层用。** 工具那条路一律走 {@link listJobs},会话之间那道墙就是
1909
+ * 靠「工具层拿不到别人那一格」立住的。
1910
+ */
1911
+ declare function allJobs(kind?: JobKind): Array<{
1912
+ sessionId: string;
1913
+ info: JobInfo;
1914
+ }>;
1915
+ interface JobReadResult {
1916
+ info: JobInfo;
1917
+ /** 这一段输出 */
1918
+ output: string;
1919
+ /** 下次传回来的游标(绝对偏移) */
1920
+ nextCursor: number;
1921
+ /** 从 `since` 到现在有多少字节已经滚出环形缓冲 */
1922
+ missed: number;
1923
+ /** 还有没有更多(这次被 `maxChunk` 截断了) */
1924
+ hasMore: boolean;
1925
+ }
1926
+ /**
1927
+ * 从绝对偏移 `since` 起取一段输出。**纯读,不动 `lastActivity`**。
1928
+ *
1929
+ * `since` 是**绝对偏移**而不是「第几次调用」:后者在两次调用之间作业又输出了
1930
+ * 一大段时会算错,而绝对偏移天然对得上。丢掉的那一段单独报 `missed`,不静默 ——
1931
+ * 模型据此知道自己看到的不是全部(怎么说出口是工具层的事,措辞在
1932
+ * `plugin-terminal/src/spill.ts`,`task_output` 和 `shell_read` 共用一份)。
1933
+ *
1934
+ * 「算不算一次活动」交给调用方显式 {@link touchJob},不在这里顺手做:
1935
+ * `shell_send` 要的游标是**写之前**那一刻的,而它的两次 touch 一次在写之前、
1936
+ * 一次在等完之后 —— 读一次就 touch 一次的话,中间那次读会把语义搅浑。
1937
+ */
1938
+ declare function readJob(sessionId: string, id: string, since: number, maxChunk: number): JobReadResult | undefined;
1939
+ /**
1940
+ * 记一次活动。持久会话的空闲回收量的就是 `lastActivity`。
1941
+ *
1942
+ * 别的会话的 id / 不存在的 id 是空操作 —— 和 {@link jobIn} 同一个出口。
1943
+ */
1944
+ declare function touchJob(sessionId: string, id: string): void;
1945
+ /**
1946
+ * 手里只有 id 时往缓冲里追加一段。
1947
+ *
1948
+ * 今天唯一的调用点是持久会话的「写入失败」那句话:`pty.write()` 可能在上一微秒
1949
+ * 失败(进程刚退),而那句话该进**缓冲**而不是抛出去 —— 模型读输出时才看到它,
1950
+ * 位置正好在它发的那段输入之后。热路径(每一块输出)走 {@link JobHandle.append}。
1951
+ */
1952
+ declare function appendJob(sessionId: string, id: string, chunk: string): void;
1953
+ interface JobWaitResult {
1954
+ info: JobInfo;
1955
+ /** 等超时了(作业还在跑)。**没停它** */
1956
+ timedOut: boolean;
1957
+ /**
1958
+ * 这类作业压根没有「结束」这个状态({@link JobSpec.terminates} 为 `false`)。
1959
+ * 这一档**没等**,是立刻回的 —— 判据见那个字段。
1960
+ */
1961
+ neverEnds: boolean;
1962
+ }
1963
+ /**
1964
+ * 等一格作业结束。
1965
+ *
1966
+ * 超时**不停它** —— 「我等不及了」和「我要停掉它」是两件事,后者有
1967
+ * {@link stopJob}。把它们合并的话,模型一次 `task_wait` 超时就会把一个跑了
1968
+ * 十分钟的构建白白毁掉。
1969
+ */
1970
+ declare function waitJob(sessionId: string, id: string, timeoutMs: number): Promise<JobWaitResult | undefined>;
1971
+ /**
1972
+ * 停掉一格作业(怎么停由产出方给的 {@link JobSpec.stop} 决定)。
1973
+ * 已经不在跑的返回 `false`,别的会话的 id 也是 —— 同一个出口,判据见 {@link jobIn}。
1974
+ *
1975
+ * ⚠️ **状态是同步标好的,`await` 的只是「收干净了」**:持久会话的空闲回收
1976
+ * `void` 掉这个 promise(它被每一次工具调用同步调一遍,而收进程树要 pgrep),
1977
+ * 靠的正是这一条 —— 下一次 `shell_send` 立刻看得到 `reaped`,不会静默失败。
1978
+ *
1979
+ * @param status 记成什么。不给就用 {@link JobSpec.stoppedStatus},再不给是 `killed`
1980
+ */
1981
+ declare function stopJob(sessionId: string, id: string, status?: JobStatus): Promise<boolean>;
1982
+ /**
1983
+ * `/resume` 换了会话 id,指定 kind 的那几格跟着搬(方案 25 PR-4)。
1984
+ *
1985
+ * @param kinds 搬哪几类。**必须点名**,因为今天两类的答案不一样:后台命令要搬
1986
+ * (「它可能还在跑一个十分钟的构建,而搬不搬决定它还停不停得掉」),持久会话
1987
+ * **不搬**(那个 venv 还算不算「这段对话的」是一次产品决定,记在方案 49 的
1988
+ * 验收记录里)。缺省全搬的话,那次刻意的不作为会在这一轮被静默改掉。
1989
+ *
1990
+ * 目标会话已经有一格时**合并**而不是覆盖。id 全进程唯一,所以合并不会撞键。
1991
+ */
1992
+ declare function rekeyJobs(from: string, to: string, kinds: readonly JobKind[]): void;
1993
+ /**
1994
+ * 忘掉作业记录(**不停进程**)。只给用例用。
1995
+ *
1996
+ * 真正的回收走隔壁那张按 pid 记的表,它才是所有长期子进程的唯一真源;这里只负责
1997
+ * 忘掉记录。**这个函数只给用例用,所以下面说的是用例那条路**:清场钩子里的正确
1998
+ * 顺序是**先** `killAllTrackedProcesses()`(用例要的正是「机器上一个都不剩」这个
1999
+ * 跨表口径)**再**这一下 —— 反过来的话那几个进程还活着,而唯一记着它们的表已经
2000
+ * 空了。
2001
+ *
2002
+ * ⚠️ 别把上面那句读成生产路径:宿主 `dispose()` 走的是
2003
+ * `OwnedProcessTable.release()`(只收自己那一张),`killAllTrackedProcesses()`
2004
+ * 在生产路径上零调用方。判据全文在 `child-process.ts` 那个函数的头上。
2005
+ *
2006
+ * @param kinds 只忘掉这几类。**不给 = 全清**。收窄这个参数不是洁癖:下沉之后
2007
+ * 两族的清场函数(`clearAllTasks` / `clearAllShells`)落在同一张表上,
2008
+ * 不点名的话一个只关心后台任务的用例会顺手把另一族的记录也清掉 ——
2009
+ * 那种互相干扰在同文件多用例里表现成「单跑绿、全量红」。
2010
+ * id 计数器也只重置点名的那几个(用例普遍依赖第一个作业叫 `t1` / `s1`)。
2011
+ */
2012
+ declare function clearAllJobs(kinds?: readonly JobKind[]): void;
2013
+
1565
2014
  /**
1566
2015
  * 长期子进程 —— 启动、登记、回收(方案 36)。
1567
2016
  *
1568
2017
  * 「长期」的意思是**活得比一次工具调用长**:后台的 `pnpm dev`、
1569
- * 将来方案 38 的 language server。它们和 `runCommand` 那种「起来跑完就没了」的
1570
- * 一次性命令有两点不同:
2018
+ * 方案 38 的 language server、方案 49 的持久 shell 会话。它们和 `runCommand`
2019
+ * 那种「起来跑完就没了」的一次性命令有两点不同:
1571
2020
  *
1572
2021
  * 1. 没人在 await 它,所以**必须有一张表**记着,否则宿主退出时收不回来
1573
2022
  * 2. 输出没有接收者,所以**必须自己接住**,否则管道写满 64KB 之后子进程会阻塞
@@ -1588,6 +2037,31 @@ declare function buildBwrapArgs(command: string, commandArgs: string[], opts: Bu
1588
2037
  *
1589
2038
  * > 改造前 `runBackground` 用的是 `detached: true` + `unref()`,恰好是反的:
1590
2039
  * > Ctrl+C 到不了它们,`kill -9 epoch` 之后用户机器上会留下一堆 `pnpm dev`。
2040
+ *
2041
+ * ## ⚠️ 持久 PTY 会话(方案 49)**指望不上上面那个内核保底**
2042
+ *
2043
+ * 「同一个前台进程组」这条对 `spawn` 起的子进程成立,对 PTY **不成立**:
2044
+ * `forkpty()` 在子进程里第一件事就是 `setsid()`,它必然是**另一个会话**的组长,
2045
+ * 终端把 SIGINT 发给我们这一组时它压根收不到(判据原文在
2046
+ * [plugin-terminal 的 pty.ts](../../plugins/plugin-terminal/src/pty.ts) 文件头)。
2047
+ *
2048
+ * 所以持久会话的两条腿是**分开**的,别把它们当成一条:
2049
+ *
2050
+ * | 退出方式 | 靠谁收干净 |
2051
+ * | --------------- | ------------------------------------------------------------------- |
2052
+ * | Ctrl+C / 正常退 | **靠这张表** —— `dispose()` 等 {@link OwnedProcessTable.release} 跑完再 exit |
2053
+ * | `kill -9 epoch` | 靠内核:pty **主设备**跟着进程一起关,从设备那侧收到 SIGHUP |
2054
+ *
2055
+ * 第一行是这张表在方案 49 里存在的全部理由({@link trackForeignProcess}),
2056
+ * 也是那一轮验收 12「把登记摘掉 → 验收 8 当场变红」量的东西。
2057
+ *
2058
+ * ⚠️ **收的那一下是 `release()`,不是 {@link killAllTrackedProcesses}**(方案 60)。
2059
+ * 差别不是叫法:`release()` 收的是**这一份 runtime 自己那张**(外加引用计数归零时
2060
+ * 的兜底表),而 `killAllTrackedProcesses()` 收的是**所有表**、**不该**出现在
2061
+ * dispose 那条路上 —— 装进去就是方案 60 在修的那条 bug(一条定时任务跑完把宿主
2062
+ * agent 的后台任务全端掉,一个字不报)。接线在 `runtime/src/build.ts` 的
2063
+ * `dispose`,Ctrl+C 那一路是 `installSignalHandlers` 的
2064
+ * `dispose().finally(() => process.exit(0))`。
1591
2065
  */
1592
2066
 
1593
2067
  /**
@@ -1641,26 +2115,133 @@ interface StartLongLivedOptions {
1641
2115
  onExit?: (exitCode: number | null, signal: NodeJS.Signals | null) => void;
1642
2116
  }
1643
2117
  /**
1644
- * 起一个长期子进程并登记。
2118
+ * 一张长期子进程表。
2119
+ *
2120
+ * ## ⚠️ 2026-08-20(方案 60):这里以前是**一张模块级 Map**
2121
+ *
2122
+ * 那一版的注释写着「同一个进程里开多个 runtime 时,先 dispose 的那个会把别人的
2123
+ * 也收掉。**今天没有这种宿主**」。后半句已经不成立了,而且不是因为出现了那种宿主 ——
2124
+ * **是因为我们自己**:`runtime.schedules.fire()` 在一个已经有 runtime 的进程里再
2125
+ * `buildRuntime()`、跑完 `dispose()`(`runtime/src/schedule/executor.ts` 的 `runOnce`),
2126
+ * 而 `buildRuntime()` 的**失败路径**连 dispose 都不用等就收一遍。表现是:
2127
+ *
2128
+ * > agent 起了几个 `terminal(background: true)` → 宿主跑一条定时任务 →
2129
+ * > 那一程结束时把 agent 的后台任务全收了。而服务、日志、状态码、诊断全部正常,
2130
+ * > **一个字不报**(作业表还挂着 `running`,模型和用户都以为它活着)。
2131
+ *
2132
+ * ## 所有权就是「在哪张表里」,不是一个字段
2133
+ *
2134
+ * **一个 pid 只可能在一张表里。** 这一条是刻意的:判「这个进程归谁」如果发生在
2135
+ * *收*的那一刻(按时间戳过滤、按一个 `owner` 字段比一下),判错了没有任何地方会红 ——
2136
+ * 多收是静默端掉别人的构建,少收是静默泄漏。放在*登记*那一刻,登记方就必须说出
2137
+ * 「进哪张」,说不出来的地方在编译期就写不出来。
2138
+ */
2139
+ interface ProcessTable {
2140
+ /**
2141
+ * 起一个长期子进程并登记进**这张**表。
2142
+ *
2143
+ * `stdio` 一律是 `pipe`:**不能用 `'ignore'`** —— 那样 `task_output` 无从取起;
2144
+ * 也**不能用 `'inherit'`** —— 后台任务的输出直接打到用户终端上会把 TUI 冲烂。
2145
+ * 管道必须被读空,否则子进程写满内核缓冲区(Linux 上 64KB)之后会永久阻塞在
2146
+ * `write()` 上,表现成「后台任务莫名其妙不动了」。所以即使调用方不给
2147
+ * `onOutput`,这里也照样订阅 `data` 事件。
2148
+ */
2149
+ start(opts: StartLongLivedOptions): TrackedProcess;
2150
+ /**
2151
+ * 登记一个**别人起的**长期进程(方案 49)。
2152
+ *
2153
+ * 起进程这件事仍然归调用方 —— 这里只收下「它是谁」和「怎么收它」。今天的调用方
2154
+ * 是 plugin-terminal 的两条 PTY 路径:`node-pty` 的 `forkpty()` 不经过 `spawn`,
2155
+ * 所以它们进不了 {@link ProcessTable.start} 那条路,但它们**必须**进某张表 ——
2156
+ * 判据见 {@link TrackedEntry},以及这个文件头那条「不活过 epoch 进程」。
2157
+ *
2158
+ * `kill` 由调用方给,因为收法确实不同:PTY 的子进程 `setsid()` 过,是自己那一组
2159
+ * 的组长(可以放心杀组),而且 forkpty / ConPTY 那侧另有资源,光杀 pid 收不干净。
2160
+ * 让这一层去猜「你是不是 PTY」等于把 plugin 的知识抄进 infra。
2161
+ *
2162
+ * @returns 摘除函数。进程**自己**结束时调它 —— 不调的下场是表里留一个死 pid,
2163
+ * 收尾时于是每次都去杀一个早就没了的 pid(无害但白跑),而
2164
+ * {@link trackedProcessCount} 会一直多报一个。
2165
+ */
2166
+ trackForeign(pid: number, kill: () => Promise<void>): () => void;
2167
+ /** 把**这张**表里的全部收掉 */
2168
+ killAll(): Promise<void>;
2169
+ /** 这张表里还活着几个 */
2170
+ count(): number;
2171
+ }
2172
+ /**
2173
+ * 每个 runtime 一份的那种表({@link createProcessTable} 开出来的)。
1645
2174
  *
1646
- * `stdio` 一律是 `pipe`:**不能用 `'ignore'`** —— 那样 `task_output` 无从取起;
1647
- * 也**不能用 `'inherit'`** —— 后台任务的输出直接打到用户终端上会把 TUI 冲烂。
1648
- * 管道必须被读空,否则子进程写满内核缓冲区(Linux 上 64KB)之后会永久阻塞在
1649
- * `write()` 上,表现成「后台任务莫名其妙不动了」。所以即使调用方不给
1650
- * `onOutput`,这里也照样订阅 `data` 事件。
2175
+ * {@link ProcessTable} 多的两个方法就是「所有权」在类型上的那一半:
2176
+ * 认领哪些会话是我的,以及交还。
2177
+ */
2178
+ interface OwnedProcessTable extends ProcessTable {
2179
+ /**
2180
+ * 把一段会话记到这张表名下 —— 此后这段会话起的长期进程都进这张表。
2181
+ *
2182
+ * 钥匙选 `sessionId` 而不是在 `ToolContext` 上加一格表:那一格要 `ChildProcess`
2183
+ * 这样的 node 类型,而 protocol 是零依赖的契约层;而且宿主自己实现的工具拿不到
2184
+ * 那一格,于是它必然可选 —— 可选就还是要有兜底那条路,等于同时养两套。
2185
+ * `sessionId` 本来就是契约里的一等字段,作业表和审批桥已经在用同一把钥匙。
2186
+ */
2187
+ claim(sessionId: string): void;
2188
+ /**
2189
+ * 收自己这一张 + 解除全部会话绑定 + 兜底表引用 -1(归零时连兜底那张一起收)。
2190
+ *
2191
+ * **幂等**,理由同 `EpochRuntime.dispose`:宿主的 before-quit 和会话关闭很容易
2192
+ *都调一次。记账那几步全部跑在第一个 `await` 之前 —— 不 await 的调用方因此
2193
+ * 拿到与改造前一致的引用计数行为。
2194
+ */
2195
+ release(): Promise<void>;
2196
+ }
2197
+ /** 开一张 runtime 自己的表,同时给兜底表 +1 引用(判据见 {@link fallbackTable}) */
2198
+ declare function createProcessTable(): OwnedProcessTable;
2199
+ /**
2200
+ * 这段会话起的长期进程该进哪张表。
2201
+ *
2202
+ * 没被任何表认领过 → 兜底那张。**这个方向是刻意选的**:漏认领的后果是「收得晚」
2203
+ * (等最后一个 runtime 走),而不是「收了别人的」—— 后者正是这一轮在修的 bug。
2204
+ */
2205
+ declare function processTableFor(sessionId?: string): ProcessTable;
2206
+ /** 兜底表本身。给「明确知道自己不属于任何 runtime」的调用方用 */
2207
+ declare function processFallbackTable(): ProcessTable;
2208
+ /**
2209
+ * 起一个长期子进程并登记进**兜底表**。
2210
+ *
2211
+ * 保留这个自由函数是给「没有会话」的调用方(plugin-lsp 的池、用例)。
2212
+ * 有 `sessionId` 的一律走 `processTableFor(sessionId).start(...)` ——
2213
+ * 否则那个进程会被别的 runtime 的 dispose 收掉。
1651
2214
  */
1652
2215
  declare function startLongLivedProcess(opts: StartLongLivedOptions): TrackedProcess;
2216
+ /** 同上,登记进**兜底表**。判据见 {@link ProcessTable.trackForeign} */
2217
+ declare function trackForeignProcess(pid: number, kill: () => Promise<void>): () => void;
1653
2218
  /**
1654
- * 杀掉一个登记过的进程**及其整棵树**。
2219
+ * 杀掉一个登记过的进程**及其整棵树**。它在哪张表里由 {@link owners} 索引答。
1655
2220
  *
1656
2221
  * 杀树而不是杀组:`detached: false` 意味着我们和它在同一个进程组,
1657
2222
  * `kill(-pgid)` 会把**我们自己**也打进去。树遍历(`pgrep -P` / `taskkill /t`)
1658
2223
  * 是这个形态下唯一正确的做法 —— 这正是 `killProcessTree` 存在的理由。
2224
+ *
2225
+ * ⚠️ 上面这段只对 `child` 那一支成立。`foreign` 那支(PTY)走**它自己给的**
2226
+ * `kill`:它 `setsid()` 过,和我们不在同一个进程组,杀组不会打到我们自己。
1659
2227
  */
1660
2228
  declare function killTrackedProcess(pid: number): Promise<void>;
1661
- /** 还活着的登记进程数 */
2229
+ /** 还活着的登记进程数 —— **全进程**(跨所有表数),用例要的正是这个口径 */
1662
2230
  declare function trackedProcessCount(): number;
1663
- /** 全部收掉。宿主 `dispose()` 时调 */
2231
+ /**
2232
+ * **所有表**全部收掉 —— 「这个进程整个不要了」那一档。
2233
+ *
2234
+ * ⚠️ 宿主 `dispose()` **不该**调它(那是 {@link OwnedProcessTable.release} 的活,
2235
+ * 它只收自己那一张)。
2236
+ *
2237
+ * **生产路径上今天零调用方** —— 全仓命中的全是用例的清场钩子(`afterAll` /
2238
+ * `afterEach` 里那句「机器上一个都不剩」,那正是用例要的口径)。留着它不是遗漏,
2239
+ * 是方案 60 的刻意保留;但也别照着它写注释:`cleanupBackgroundProcesses()`
2240
+ * 不带参数那条路走的是 {@link processFallbackTable} 的 `killAll()`,**不经过这里**。
2241
+ *
2242
+ * 「宿主 dispose 调它」这句话在四个包的注释里活过一轮(2026-08-21 一次性改掉),
2243
+ * 现在由仓库根 `__tests__/process-table-claims.test.ts` 钉着不许再回来。
2244
+ */
1664
2245
  declare function killAllTrackedProcesses(): Promise<void>;
1665
2246
 
1666
2247
  /**
@@ -2000,13 +2581,6 @@ interface I18nDiagnostic {
2000
2581
  * 界面文案加载进来。
2001
2582
  */
2002
2583
  declare function localesDir(): string | undefined;
2003
- /**
2004
- * 读一份 catalog。**失败不抛** —— 返回空表 + 一条 error,调用方(`t()`)
2005
- * 自然落进回落链的下一层。
2006
- *
2007
- * 结果带缓存:`t()` 在渲染热路径上,每次调用都去 stat 一次文件不合适。
2008
- * 用例改了 `EPOCH_LOCALES_DIR` 之后调 {@link resetI18n} 清掉。
2009
- */
2010
2584
  declare function loadCatalog(lang: Lang): CatalogLoad;
2011
2585
  /** 清掉 catalog 缓存、目录探测缓存和已积累的诊断。**只给用例用** */
2012
2586
  declare function resetI18n(): void;
@@ -2046,7 +2620,7 @@ declare function currentLang(): Lang;
2046
2620
  *
2047
2621
  * ## ⚠️ 「几百处」这句 2026-08-17 收窄了:**对全仓成立,对网线那一段不成立**
2048
2622
  *
2049
- * [方案 58 §1.2](../../../.agents/plans/58-wire-lang-negotiation-plan.md) 数了一遍:
2623
+ * [方案 58 §1.2](../../../docs/verify/VERIFY_RECORD-58-wire-lang-negotiation.md) 数了一遍:
2050
2624
  * 全仓非浏览器的 `t('…')` 调用点是 **272 处**,其中**会把产物送上网线的只有 80 上下**
2051
2625
  * (`server` 41 + `runtime` 的诊断那一批 + `core` / `infra` 少数几处),
2052
2626
  * 而 **`cli` 那 175 处一处都不用动** —— 它们打在终端上,跟着进程 locale 走才是对的。
@@ -2065,6 +2639,31 @@ declare function currentLang(): Lang;
2065
2639
  * 压根 import 不到它(判据在 `runtime/src/index.ts`)。**别为了方便把它转出来。**
2066
2640
  */
2067
2641
  declare function setLang(lang: Lang): void;
2642
+ /**
2643
+ * 给 `toLocaleString` 一类 API 用的 BCP-47 标签,**跟界面语言走**。
2644
+ *
2645
+ * ## 为什么不直接 `toLocaleString()`
2646
+ *
2647
+ * 不传参数走的是**系统** locale,而界面语言可以被 `display.language` /
2648
+ * `EPOCH_LANGUAGE` 压过 —— 于是一个在 zh-CN 机器上把界面切成 English 的用户,
2649
+ * 会在一屏英文里看到 `2026/8/22 14:30:00`。那种半中半英的形态正是方案 40 / 58
2650
+ * 这两轮在治的病,而日期这一处最容易漏,因为它不是字面量、
2651
+ * `check:i18n` 和 catalog 一致性两道门禁都扫不到它。
2652
+ *
2653
+ * ## 为什么是这里、而且是个函数
2654
+ *
2655
+ * 2026-08-22 之前 `cli` 里已经手写了三份 `currentLang() === 'zh' ? … : …`
2656
+ * (mcp / trust / sessions),第四处就该出分叉了。放在 `currentLang` 旁边是因为
2657
+ * 映射表的**唯一正确性判据**是「界面语言有哪几种」,而那个值域就在这个文件里。
2658
+ *
2659
+ * 必须是函数不是常量:语言在 `setLang()` 之后才定下来,模块级常量会把探测到的
2660
+ * 那个值永久钉住 —— 同 `tui/src/i18n.ts` 文件头那条。
2661
+ *
2662
+ * `zh` → `zh-CN` 而不是 `zh`:catalog 里的字是简体,`zh` 在 ICU 里落到的默认
2663
+ * 地区未必是大陆。`en` → `en-US` 而不是 `en`:日期顺序(月/日 vs 日/月)在
2664
+ * `en` 上由 ICU 挑,写死一个才不会随 Node 版本变。
2665
+ */
2666
+ declare function uiDateLocale(): string;
2068
2667
  /**
2069
2668
  * 系统 locale 的信号,**从高到低**。
2070
2669
  *
@@ -2337,9 +2936,11 @@ declare function migrateEnvSecrets(opts: MigrateOptions): Promise<MigrateResult>
2337
2936
 
2338
2937
  declare class PlaintextStore implements SecretStore {
2339
2938
  private readonly envFilePath;
2939
+ private readonly reason;
2340
2940
  readonly backend = "plaintext";
2341
2941
  readonly encrypted = false;
2342
- readonly detail: string;
2942
+ /** 取值器而不是属性:属性在构造那一刻就定了,而语言可能还没落定 */
2943
+ get detail(): string;
2343
2944
  /**
2344
2945
  * @param reason 为什么降级。**必须带修复方法** —— 只说「明文」而不说怎么修,
2345
2946
  * 用户除了忍着没有别的选择
@@ -2465,4 +3066,4 @@ declare function getSecretValues(): Record<string, string>;
2465
3066
  /** 仅供测试:把进程级状态复位 */
2466
3067
  declare function resetSecretState(): void;
2467
3068
 
2468
- export { type ArtifactHandle, type BubblewrapOptions, CODE_EXEC_ROOTS, type CatalogLoad, type CommandSegment, type Confinement, type CreateSecretStoreOptions, DEFAULT_LANG, DEFAULT_YAML, DENIAL_SIGNATURES, type DangerCheckOptions, type DangerMatch, type DangerPlatform, type DiagnosticCollector, ENVELOPE_PREFIX, EXEC_PATH_AS_NODE_ENV, type FailureKind, type FailureVerdict, type I18nDiagnostic, IS_WINDOWS, type IsolatedCommand, type IsolationBackend, type IsolationOptions, type KillProcessTreeOptions, type KillSignalOptions, type KillablePty, LANGS, type Lang, type LogEntry, type LogLevel, type MigrateOptions, type MigrateResult, type Migration, type OnExisting, PROJECT_DIR_NAME, type ParseIssue, type ParseOutcome, type ParsedCommand, PlaintextStore, RIPGREP_INSTALL_HINT, RUNNER_FAILURE_RULES, type ResolveRipgrepOptions, type RipgrepMode, type RipgrepResolution, type RunnerFailureRules, SECRET_MODE, SECRET_SERVICE, SHELL_KINDS, SIGKILL_TIMEOUT_MS, type SandboxEnforcement, type SandboxMode, type SandboxPolicy, type SeatbeltOptions, type SecretBackendId, SecretCatalog, type SecretStore, type ShellFlavor, type ShellInvocation, type ShellKind, type SqliteDatabase, type StartLongLivedOptions, type StreamDecoder, TOOLCHAIN_CACHE_DIRS, type TrackedProcess, WINDOWS_HIDE_FLAGS, agentsDir, approvalsPath, artifactsDir, assertValidSecretName, automationDir, automationLogsDir, automationWorkDir, budgetStatePath, buildBwrapArgs, buildProfile, checkDangerousCommand, checkObfuscation, checkpointsDir, classifyFailure, closeAllDatabases, collectProcessTree, commandsDir, configPath, confine, countCodePoints, createLogger, createSecretStore, createStreamDecoder, currentLang, dbPath, detectBackend, detectConsoleEncoding, encodingForCodePage, envPath, formatIssues, generateDataKey, getDataKey, getDefaultShell, getPythonCommand, getSecretStore, getSecretValues, hasSideEffectChannel, headCodePoints, hooksPath, i18nDiagnostics, isEnvelope, isInWorkspace, isLang, isReadOnlyCommand, isSensitiveKey, isolate, issueDetails, keepHeadAndTail, keybindingsPath, killAllTrackedProcesses, killPids, killProcessTree, killTrackedProcess, listStoredSecretNames, loadCatalog, localesDir, managedSettingsPath, marketplacesPath, maskApiKey, maskSensitive, mcpAuthPath, mcpConfigPath, mcpSchemaCachePath, memoriesDir, migrate, migrateEnvSecrets, normalizeForMatch, openArtifact, openDatabase, openEnvelope, parseDataKey, parseEnvText, parseLenient, parseShellCommand, parseStrict, placeholdersOf, pluginsDir, pluginsStatePath, policiesDir, powershellCommandArg, prefetchProviderSecrets, probeBubblewrap, probeSeatbelt, probeShell, projectCommandsDir, projectHooksPath, projectLocalSettingsPath, projectPoliciesDir, projectSchemasDir, projectSettingsPath, projectSkillsDir, readConfigYaml, readKey, refCount, releaseDatabase, removeEnvVarText, resetI18n, resetRipgrepCache, resetSecretState, resolveHomeDir, resolveLang, resolveProfile, resolveRipgrep, resolveShellKind, safeInit, sandboxModeForLevel, schemaVersion, sealEnvelope, setDataKey, setEnvVarText, setLang, setLogLevel, setScalar, setSecretStore, setSecretValues, setSectionField, setShell, shellPtyArgs, shellSpawnArgs, skillsDir, splitWords, startLongLivedProcess, stripShellWrapper, systemLocaleSignals, t, tailCodePoints, trackedProcessCount, trustPath, trustedImportsPath, unknownKeyIssues, unsetScalar, unsetSectionField, withTimeout, workspacesPath, worktreesDir, writeArtifact, writeConfigYaml, writeEnvFile };
3069
+ export { type ArtifactHandle, type BubblewrapOptions, CLAUDE_DIR_NAME, CODE_EXEC_ROOTS, type CatalogLoad, type CommandSegment, type Confinement, type CreateSecretStoreOptions, DEFAULT_LANG, DEFAULT_YAML, DENIAL_SIGNATURES, type DangerCheckOptions, type DangerMatch, type DangerPlatform, type DiagnosticCollector, ENVELOPE_PREFIX, EXEC_PATH_AS_NODE_ENV, type FailureKind, type FailureVerdict, type I18nDiagnostic, IS_WINDOWS, type IsolatedCommand, type IsolationBackend, type IsolationOptions, type JobHandle, type JobInfo, type JobKind, type JobReadResult, type JobSpec, type JobStatus, type JobWaitResult, type KillProcessTreeOptions, type KillSignalOptions, type KillablePty, LANGS, type Lang, type LogEntry, type LogLevel, type MigrateOptions, type MigrateResult, type Migration, type OnExisting, type OwnedProcessTable, PROJECT_DIR_NAME, type ParseIssue, type ParseOutcome, type ParsedCommand, PlaintextStore, type ProcessTable, RUNNER_FAILURE_RULES, type ResolveRipgrepOptions, type RipgrepMode, type RipgrepResolution, type RunnerFailureRules, SECRET_MODE, SECRET_SERVICE, SHELL_KINDS, SIGKILL_TIMEOUT_MS, type SandboxEnforcement, type SandboxMode, type SandboxPolicy, type SeatbeltOptions, type SecretBackendId, SecretCatalog, type SecretStore, type ShellFlavor, type ShellInvocation, type ShellKind, type SqliteDatabase, type StampedPid, type StartLongLivedOptions, type StreamDecoder, TOOLCHAIN_CACHE_DIRS, type TrackedProcess, WINDOWS_HIDE_FLAGS, agentsDir, allJobs, appendJob, approvalsPath, artifactsDir, assertValidSecretName, automationDir, automationLogsDir, automationWorkDir, budgetStatePath, buildBwrapArgs, buildProfile, checkDangerousCommand, checkObfuscation, checkpointsDir, classifyFailure, claudeUserSettingsPath, clearAllJobs, closeAllDatabases, collectProcessTree, collectProcessTreeStamped, commandsDir, configPath, confine, countCodePoints, createLogger, createProcessTable, createSecretStore, createStreamDecoder, currentLang, dbPath, detectBackend, detectConsoleEncoding, encodingForCodePage, envPath, formatIssues, generateDataKey, getDataKey, getDefaultShell, getJob, getPythonCommand, getSecretStore, getSecretValues, hasSideEffectChannel, headCodePoints, hooksPath, i18nDiagnostics, isEnvelope, isInWorkspace, isLang, isReadOnlyCommand, isSensitiveKey, isolate, issueDetails, jobDetail, keepHeadAndTail, keybindingsPath, killAllTrackedProcesses, killPids, killProcessTree, killStampedPids, killTrackedProcess, listJobs, listStoredSecretNames, loadCatalog, localesDir, managedSettingsPath, marketplacesPath, maskApiKey, maskSensitive, mcpAuthPath, mcpConfigPath, mcpSchemaCachePath, memoriesDir, migrate, migrateEnvSecrets, normalizeForMatch, openArtifact, openDatabase, openEnvelope, parseDataKey, parseEnvText, parseLenient, parseShellCommand, parseStrict, placeholdersOf, pluginsDir, pluginsStatePath, policiesDir, powershellCommandArg, prefetchProviderSecrets, probeBubblewrap, probeSeatbelt, probeShell, processFallbackTable, processTableFor, projectClaudeLocalSettingsPath, projectClaudeSettingsPath, projectCommandsDir, projectHooksPath, projectLocalSettingsPath, projectPoliciesDir, projectSchemasDir, projectSettingsPath, projectSkillsDir, readConfigYaml, readJob, readKey, refCount, registerJob, rekeyJobs, releaseDatabase, removeEnvVarText, resetI18n, resetRipgrepCache, resetSecretState, resolveHomeDir, resolveLang, resolveProfile, resolveRipgrep, resolveShellKind, ripgrepInstallHint, safeInit, sandboxModeForLevel, schemaVersion, sealEnvelope, setDataKey, setEnvVarText, setLang, setLogLevel, setScalar, setSecretStore, setSecretValues, setSectionField, setShell, shellPtyArgs, shellSpawnArgs, skillsDir, splitWords, startLongLivedProcess, stopJob, stripShellWrapper, systemLocaleSignals, t, tailCodePoints, touchJob, trackForeignProcess, trackedProcessCount, trustPath, trustedImportsPath, uiDateLocale, unknownKeyIssues, unsetScalar, unsetSectionField, waitJob, withTimeout, workspacesPath, worktreesDir, writeArtifact, writeConfigYaml, writeEnvFile };