@sema-agent/server 7.4.0 → 7.6.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 (201) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +18 -3
  3. package/README.zh-CN.md +14 -3
  4. package/USAGE.md +80 -1
  5. package/dist/approval-card.d.ts +15 -3
  6. package/dist/approval-card.js +41 -7
  7. package/dist/approval-reconciler.d.ts +109 -12
  8. package/dist/approval-reconciler.js +152 -24
  9. package/dist/boot/config-center.js +15 -2
  10. package/dist/boot/coordinators.js +10 -2
  11. package/dist/boot/execution-env.js +1 -1
  12. package/dist/boot/org-memory.d.ts +6 -0
  13. package/dist/boot/org-memory.js +1 -1
  14. package/dist/boot/parked-revive-gate.d.ts +78 -0
  15. package/dist/boot/parked-revive-gate.js +114 -0
  16. package/dist/boot/reapers.d.ts +2 -0
  17. package/dist/boot/reapers.js +11 -4
  18. package/dist/boot/resolve-spec.d.ts +3 -19
  19. package/dist/boot/resolve-spec.js +73 -67
  20. package/dist/boot/runner-deps.d.ts +23 -1
  21. package/dist/boot/runner-deps.js +8 -11
  22. package/dist/boot/workflow-orchestration.d.ts +8 -3
  23. package/dist/boot/workflow-orchestration.js +23 -1
  24. package/dist/budget.d.ts +1 -1
  25. package/dist/budget.js +1 -1
  26. package/dist/capabilities/repo-tools.d.ts +1 -1
  27. package/dist/capabilities/repo-tools.js +8 -2
  28. package/dist/config-center/apply-effective.js +33 -10
  29. package/dist/config-provider.d.ts +1 -0
  30. package/dist/config-provider.js +23 -3
  31. package/dist/config-types.d.ts +27 -9
  32. package/dist/config.d.ts +6 -1
  33. package/dist/config.js +61 -15
  34. package/dist/deployment-governance.d.ts +168 -0
  35. package/dist/deployment-governance.js +206 -0
  36. package/dist/env-facts.d.ts +3 -1
  37. package/dist/env-facts.js +3 -1
  38. package/dist/fleet/fleet-bus.d.ts +17 -2
  39. package/dist/fleet/fleet-bus.js +68 -3
  40. package/dist/fleet/fleet-terminal-window.d.ts +98 -0
  41. package/dist/fleet/fleet-terminal-window.js +316 -0
  42. package/dist/governance-ask-marks.d.ts +31 -0
  43. package/dist/governance-ask-marks.js +122 -0
  44. package/dist/hooks/hook-runner.d.ts +28 -0
  45. package/dist/hooks/hook-runner.js +149 -25
  46. package/dist/http/routes/approvals-assistant.js +6 -7
  47. package/dist/http/routes/diagnostics.js +10 -5
  48. package/dist/http/routes/fleet.js +160 -14
  49. package/dist/http/routes/memory-policy.d.ts +2 -1
  50. package/dist/http/routes/memory-policy.js +77 -13
  51. package/dist/http/routes/runs.js +6 -2
  52. package/dist/http/routes/tasks.js +59 -22
  53. package/dist/http/routes/trace-usage.js +3 -4
  54. package/dist/http/send.d.ts +23 -0
  55. package/dist/http/send.js +23 -0
  56. package/dist/http/server.d.ts +9 -0
  57. package/dist/http/server.js +28 -14
  58. package/dist/http/sse-log.js +3 -4
  59. package/dist/http/wire-types.d.ts +7 -2
  60. package/dist/leader/diffout.d.ts +10 -0
  61. package/dist/leader/diffout.js +14 -2
  62. package/dist/leader/diffup.js +3 -2
  63. package/dist/leader/planner.js +7 -0
  64. package/dist/main.js +39 -31
  65. package/dist/observability/fail-open.d.ts +17 -2
  66. package/dist/observability/fail-open.js +19 -4
  67. package/dist/observability/prompt-manifest.d.ts +5 -1
  68. package/dist/orchestration/workflow-notify-journal.d.ts +58 -2
  69. package/dist/orchestration/workflow-notify-journal.js +130 -45
  70. package/dist/parked-decide.js +9 -4
  71. package/dist/plugins/approval-ask-store-memory.d.ts +2 -2
  72. package/dist/plugins/approval-ask-store-memory.js +3 -2
  73. package/dist/plugins/approval-ask-store-sql.d.ts +60 -5
  74. package/dist/plugins/approval-ask-store-sql.js +75 -35
  75. package/dist/plugins/background-agent-store-sql.js +16 -16
  76. package/dist/plugins/background-shell-support.d.ts +1 -1
  77. package/dist/plugins/background-shell-support.js +2 -2
  78. package/dist/plugins/breaker-state-sql.js +2 -2
  79. package/dist/plugins/checkpoint-store-sql.d.ts +67 -8
  80. package/dist/plugins/checkpoint-store-sql.js +76 -13
  81. package/dist/plugins/image-bake-store-sql.d.ts +1 -1
  82. package/dist/plugins/image-bake-store-sql.js +27 -27
  83. package/dist/plugins/image-index-sql.js +15 -15
  84. package/dist/plugins/local-checkpoint-store.d.ts +20 -1
  85. package/dist/plugins/local-checkpoint-store.js +19 -0
  86. package/dist/plugins/mailbox-store-sql.d.ts +4 -10
  87. package/dist/plugins/mailbox-store-sql.js +59 -6
  88. package/dist/plugins/memory-engine-pg.js +9 -9
  89. package/dist/plugins/memory-engine-tidb.js +7 -7
  90. package/dist/plugins/memory-sync-store-pg.js +13 -13
  91. package/dist/plugins/memory-sync-store-tidb.js +5 -5
  92. package/dist/plugins/outcome-ledger-sql.js +7 -7
  93. package/dist/plugins/pg-cost-quota.js +3 -3
  94. package/dist/plugins/pg-pool.js +84 -75
  95. package/dist/plugins/pg-rate-limiter.js +3 -3
  96. package/dist/plugins/pg-session-storage.d.ts +1 -1
  97. package/dist/plugins/pg-session-storage.js +12 -13
  98. package/dist/plugins/remote-env-host.js +3 -1
  99. package/dist/plugins/remote-env-local-docker.js +6 -3
  100. package/dist/plugins/remote-env-ssh.d.ts +13 -1
  101. package/dist/plugins/roster-store-sql.js +8 -8
  102. package/dist/plugins/store-contracts.d.ts +19 -0
  103. package/dist/plugins/store-contracts.js +42 -0
  104. package/dist/plugins/task-attachment-store.js +5 -5
  105. package/dist/plugins/task-list-store-sql.js +1 -1
  106. package/dist/plugins/tidb-cost-quota.js +1 -1
  107. package/dist/plugins/tidb-pool.js +83 -60
  108. package/dist/plugins/tidb-rate-limiter.js +1 -1
  109. package/dist/plugins/tidb-session-store.js +2 -5
  110. package/dist/plugins/tool-result-store-sql.js +2 -2
  111. package/dist/plugins/usage-window-store-sql.js +13 -13
  112. package/dist/plugins/write-behind-counter.d.ts +10 -2
  113. package/dist/plugins/write-behind-counter.js +13 -3
  114. package/dist/resource-suspend.d.ts +3 -1
  115. package/dist/resource-suspend.js +3 -1
  116. package/dist/run-local.d.ts +73 -1
  117. package/dist/run-local.js +146 -5
  118. package/dist/runs.d.ts +11 -1
  119. package/dist/runs.js +18 -3
  120. package/dist/runtime-governance.d.ts +18 -0
  121. package/dist/runtime-governance.js +90 -3
  122. package/dist/security.d.ts +12 -0
  123. package/dist/security.js +12 -0
  124. package/dist/session-sync-kernel.d.ts +13 -0
  125. package/dist/session-sync-kernel.js +13 -0
  126. package/dist/task-settings.d.ts +3 -9
  127. package/dist/task-settings.js +16 -13
  128. package/dist/tool-approval.d.ts +33 -6
  129. package/dist/tool-approval.js +80 -23
  130. package/dist/trace/core-keyset-guard.d.ts +18 -4
  131. package/dist/trace/project.d.ts +10 -1
  132. package/dist/trace/project.js +31 -0
  133. package/package.json +3 -3
  134. package/dist/boot/lexical-path-env.d.ts +0 -10
  135. package/dist/boot/lexical-path-env.js +0 -88
  136. package/dist/capabilities/oa-tools.d.ts +0 -15
  137. package/dist/capabilities/oa-tools.js +0 -54
  138. package/dist/finance/cost-taxonomy.d.ts +0 -34
  139. package/dist/finance/cost-taxonomy.js +0 -26
  140. package/dist/plugins/approval-store-sql.d.ts +0 -116
  141. package/dist/plugins/approval-store-sql.js +0 -151
  142. package/dist/plugins/file-workflow-journal-store.d.ts +0 -12
  143. package/dist/plugins/file-workflow-journal-store.js +0 -12
  144. package/dist/plugins/pg-approval-store.d.ts +0 -9
  145. package/dist/plugins/pg-approval-store.js +0 -9
  146. package/dist/plugins/pg-breaker-state.d.ts +0 -8
  147. package/dist/plugins/pg-breaker-state.js +0 -8
  148. package/dist/plugins/pg-checkpoint-store.d.ts +0 -10
  149. package/dist/plugins/pg-checkpoint-store.js +0 -10
  150. package/dist/plugins/pg-file-snapshot-store.d.ts +0 -8
  151. package/dist/plugins/pg-file-snapshot-store.js +0 -8
  152. package/dist/plugins/pg-image-bake.d.ts +0 -12
  153. package/dist/plugins/pg-image-bake.js +0 -11
  154. package/dist/plugins/pg-image-index.d.ts +0 -12
  155. package/dist/plugins/pg-image-index.js +0 -11
  156. package/dist/plugins/pg-outcome-ledger.d.ts +0 -12
  157. package/dist/plugins/pg-outcome-ledger.js +0 -11
  158. package/dist/plugins/pg-resume-anchor-store.d.ts +0 -7
  159. package/dist/plugins/pg-resume-anchor-store.js +0 -7
  160. package/dist/plugins/pg-run-store.d.ts +0 -9
  161. package/dist/plugins/pg-run-store.js +0 -9
  162. package/dist/plugins/pg-session-policy-store.d.ts +0 -7
  163. package/dist/plugins/pg-session-policy-store.js +0 -7
  164. package/dist/plugins/pg-session-store.d.ts +0 -12
  165. package/dist/plugins/pg-session-store.js +0 -12
  166. package/dist/plugins/pg-tool-result-store.d.ts +0 -9
  167. package/dist/plugins/pg-tool-result-store.js +0 -9
  168. package/dist/plugins/pg-workflow-journal-store.d.ts +0 -9
  169. package/dist/plugins/pg-workflow-journal-store.js +0 -9
  170. package/dist/plugins/pg-workflow-run-store.d.ts +0 -9
  171. package/dist/plugins/pg-workflow-run-store.js +0 -9
  172. package/dist/plugins/tidb-approval-store.d.ts +0 -8
  173. package/dist/plugins/tidb-approval-store.js +0 -8
  174. package/dist/plugins/tidb-breaker-state.d.ts +0 -7
  175. package/dist/plugins/tidb-breaker-state.js +0 -7
  176. package/dist/plugins/tidb-checkpoint-store.d.ts +0 -9
  177. package/dist/plugins/tidb-checkpoint-store.js +0 -9
  178. package/dist/plugins/tidb-file-snapshot-store.d.ts +0 -8
  179. package/dist/plugins/tidb-file-snapshot-store.js +0 -8
  180. package/dist/plugins/tidb-image-bake.d.ts +0 -12
  181. package/dist/plugins/tidb-image-bake.js +0 -11
  182. package/dist/plugins/tidb-image-index.d.ts +0 -12
  183. package/dist/plugins/tidb-image-index.js +0 -11
  184. package/dist/plugins/tidb-outcome-ledger.d.ts +0 -12
  185. package/dist/plugins/tidb-outcome-ledger.js +0 -12
  186. package/dist/plugins/tidb-resume-anchor-store.d.ts +0 -7
  187. package/dist/plugins/tidb-resume-anchor-store.js +0 -7
  188. package/dist/plugins/tidb-run-store.d.ts +0 -10
  189. package/dist/plugins/tidb-run-store.js +0 -9
  190. package/dist/plugins/tidb-session-policy-store.d.ts +0 -7
  191. package/dist/plugins/tidb-session-policy-store.js +0 -7
  192. package/dist/plugins/tidb-tool-result-store.d.ts +0 -8
  193. package/dist/plugins/tidb-tool-result-store.js +0 -10
  194. package/dist/plugins/tidb-workflow-journal-store.d.ts +0 -9
  195. package/dist/plugins/tidb-workflow-journal-store.js +0 -9
  196. package/dist/plugins/tidb-workflow-run-store.d.ts +0 -10
  197. package/dist/plugins/tidb-workflow-run-store.js +0 -10
  198. package/dist/plugins/workflow-journal-limits.d.ts +0 -12
  199. package/dist/plugins/workflow-journal-limits.js +0 -12
  200. package/dist/sema-registry.d.ts +0 -41
  201. package/dist/sema-registry.js +0 -40
@@ -0,0 +1,122 @@
1
+ /**
2
+ * [2942]/[2943] `governanceForced` 的**判定缝** —— operator 治理层产的 ask 在 server 进程内的标记通道。
3
+ *
4
+ * 🔴 为什么需要一条 out-of-band 通道(侦察结论,亲验装树 core 5.16.x dist):
5
+ * `PermissionResult.decisionReason`(`"rule" | "mode" | "hook" | "safety" | "classifier"`)**到不了帧铸点**。
6
+ * core 在 `dist/core/runner/prepare-task.js` 的 ask 铸造点(`resolveAsk({...})`,三处继承臂 + 主臂)只把
7
+ * `toolName / toolCallId / args / preview / message / askSourceIdentity() / riskAxesOf() / requiresRealApproval`
8
+ * 装进 `AskRequest`;`decisionReason` 连同整个 `PermissionResult` 一起留在 core 内部。`AskRequest` 的类型面
9
+ * (`dist/core/tool-policy.d.ts:78`)也确认没有这个键。所以「映射 decisionReason ⇒ governanceForced」这条
10
+ * 缝在**当前 core 上不存在**——它不是没接线,是没有这个字段可读。
11
+ *
12
+ * 另一侧同样不可用:`riskAxes.irreversible` 在 shellGate 上场时对**每一次** shell 调用都为真(core
13
+ * `prepare-task.js:1620-1631` 无条件 `irreversibilityTier.set("Bash"/"Monitor", …)`),与「这只 ask 是谁
14
+ * 提的」无关;`requiresRealApproval` 的生产者是 core 自己的两条安全策略(unverifiable-delete /
15
+ * transcript-integrity),与治理层无关。两者都不是判据。
16
+ *
17
+ * ⇒ 采用的方案 = **server 标记自家产物**:治理层策略(`runtime-governance.ts` 的
18
+ * `applyRuntimeGovernance`)在合成时被包一层观察器,自己产 `ask` 时把该次调用的 `toolCallId` 记进本表;
19
+ * 帧铸点(`tool-approval.ts` 的 `askBroadcast`)按 `toolCallId` 回查。
20
+ * **禁把信号藏进 message 文本**——那是 in-band hack,消费端只能靠正则猜。
21
+ *
22
+ * ## 三条硬语义
23
+ *
24
+ * 1. **只做正向断言**。命中 = 有直接证据(治理策略本人产的 ask,或治理层把 shellGate 抬到 `"always"` 后
25
+ * 的 shell 调用——该档下每一次 shell 调用都被治理层门住,推断是紧的)。**未命中 ≠ 「不是治理」**,
26
+ * 只是「没有证据」⇒ wire 上键**缺席**,绝不编一个 `false`。宁可漏标,不可错标:一个错标会把运维支去
27
+ * 翻一份根本没门住这只 ask 的治理配置。
28
+ * 2. **peek,不 consume**。同一 `toolCallId` 可能被广播两次(重试 / failover / 闭包再入,见
29
+ * `tool-approval.ts` 里 `pendingByAskId` 的顶注),消费式读会让第二帧莫名丢键。
30
+ * 3. **有界**。写入点是每一次工具调用,读取点只在 ask 上——未被读到的标记(调用被放行 / 被别的策略 deny)
31
+ * 必须能自己老化掉。按插入序淘汰最旧的(`Set` 的迭代序 = 插入序)。
32
+ *
33
+ * ## 作用域:按**审批 broker key** 分格,不是进程级一锅烩
34
+ *
35
+ * 🔴 codex 交叉复审 round3 [medium](验真):`toolCallId` **不是**全局唯一的 —— 它多数情况下是**提供方**
36
+ * 逐字给的 id(core 的 brain 层不另铸),本仓自己的代码就是这么假设的:core 把 tool-result 引用命名成
37
+ * `tr_<sessionId>_<toolCallId>`(见 `plugins/tool-result-store-sql.ts` 的 `deleteBySession` 顶注),
38
+ * `deriveAskId` 也把 `sourceTaskId`/`runId`/`legKey` 一并入哈希。一张不分格的表会**跨任务/跨租户串味**:
39
+ * 低熵 id(本地/自建模型服务常见的 `call_1` 之类)下,A 任务的治理标会被 B 任务的普通 ask 读走(假出处),
40
+ * 或被 B 的一次治理放行反手清掉(丢真出处)。
41
+ *
42
+ * 分格用的 **scope = 审批协调器的 broker key**(`["session", owner, sessionId]` / `["adhoc", owner, taskId]`)。
43
+ * 选它的理由是**两侧都够得到、且是同一个值**:
44
+ * · 写侧(治理策略的观察器跑在 core 的 `policy.check` 里,入参只有 `ToolCallRequest`)⇒ 经
45
+ * AsyncLocalStorage 拿 —— 作用域由 `runWithContext` 与审批 ctx **同拍**进出。
46
+ * · 读侧(`askBroadcast`)⇒ 由 `origin`/`primary` **显式算出**同一把 key。
47
+ *
48
+ * 🔴 读侧**显式算**而不是也读 ALS(codex 交叉复审 round5 [medium],红先复现后改的形):委派/长命 bg 子代
49
+ * 的 ask 可能在**另一条腿**的上下文里发出(宿主腿退场、壳重连出新腿,子代的卡落到新腿的流上)。读侧若取
50
+ * 环境里的那张表,拿到的是**新腿**的空表,信号静默丢失 —— 那条路径已由
51
+ * `test/governance-forced-signal.test.ts` 的「长命 bg 子代形」用例钉住(改形前它是红的)。scope 由**出处**
52
+ * 决定、与卡投递到哪条连接无关,与本仓 `AskOriginIdentity` 顶注「投递面与出处面是两件事」同源。
53
+ *
54
+ * 残余(成文):同一 (owner, sessionId) 下的**并发多 run** 共用一格 —— 那正是 broker 自己的分组粒度
55
+ * (卡本来就跨该会话投递),同租户同会话内的 id 碰撞才会互串,比进程级一锅烩窄得多。要再窄需要一个
56
+ * 写侧也够得到的 run 级身份,core 的 `ToolCallRequest` 今天给不出。
57
+ */
58
+ import { AsyncLocalStorage } from "node:async_hooks";
59
+ /** 默认表容量(全进程合计,复合键计数)。一次 turn 的工具调用数远小于此;满表淘汰的是最旧的标记
60
+ * (那些早已不可能再被读到)。 */
61
+ const DEFAULT_MAX_MARKS = 1024;
62
+ /** 复合键。JSON 元组编码(同 `streamKey`/`sessionAllowKey` 先例):任何一段里含分隔符都不可能与下一段
63
+ * 的边界产生歧义 —— 一个精心构造的 sessionId 因此无法伪造出另一格。 */
64
+ const compositeKey = (scope, toolCallId) => JSON.stringify([scope, toolCallId]);
65
+ /** 一张独立的标记表(不进程共享)。生产路径用的是下面 `governanceAskMarksFor` 给出的**分格视图**;
66
+ * 本工厂给测试(与将来的多实例形)一个不依赖任何环境的干净表。 */
67
+ export function createGovernanceAskMarks(opts) {
68
+ return createScopedView(new Set(), "", Math.max(1, Math.floor(opts?.max ?? DEFAULT_MAX_MARKS)));
69
+ }
70
+ function createScopedView(marks, scope, max) {
71
+ return {
72
+ setMarked(toolCallId, marked) {
73
+ if (typeof toolCallId !== "string" || toolCallId === "")
74
+ return; // 无身份 ⇒ 无从回查,不占格
75
+ const key = compositeKey(scope, toolCallId);
76
+ // 重标记要**刷新新鲜度**:先删后加把它挪到插入序尾部,免得一只长命的 ask 被自己后来的调用挤掉。
77
+ marks.delete(key);
78
+ if (!marked)
79
+ return; // 覆写成「不是治理门」= 清格(setMarked 顶注:复查路径的陈旧标)
80
+ marks.add(key);
81
+ while (marks.size > max) {
82
+ const oldest = marks.values().next();
83
+ if (oldest.done)
84
+ break;
85
+ marks.delete(oldest.value);
86
+ }
87
+ },
88
+ isMarked(toolCallId) {
89
+ return typeof toolCallId === "string" && toolCallId !== "" && marks.has(compositeKey(scope, toolCallId));
90
+ },
91
+ clear() {
92
+ for (const k of [...marks])
93
+ if (k.startsWith(`[${JSON.stringify(scope)},`))
94
+ marks.delete(k);
95
+ },
96
+ size() {
97
+ let n = 0;
98
+ for (const k of marks)
99
+ if (k.startsWith(`[${JSON.stringify(scope)},`))
100
+ n += 1;
101
+ return n;
102
+ },
103
+ };
104
+ }
105
+ /** 进程级底表(分格由复合键承担,见顶注「作用域」段)。 */
106
+ const processMarks = new Set();
107
+ /** 写侧的 scope 载体 —— 由 `ToolApprovalCoordinator.runWithContext` 与审批 ctx 同拍进出。 */
108
+ const scopeAls = new AsyncLocalStorage();
109
+ /** 进入一条 run 腿的标记作用域(唯一调用点 = `ToolApprovalCoordinator.runWithContext`)。 */
110
+ export function runWithGovernanceAskScope(scope, fn) {
111
+ return scopeAls.run(scope, fn);
112
+ }
113
+ /** **写侧**入口:当前异步作用域那一格的视图;作用域外 = `undefined`(写 no-op,见顶注)。 */
114
+ export function currentGovernanceAskMarks() {
115
+ const scope = scopeAls.getStore();
116
+ return scope === undefined ? undefined : createScopedView(processMarks, scope, DEFAULT_MAX_MARKS);
117
+ }
118
+ /** **读侧**入口:按**显式** scope 取视图(读侧不读 ALS —— 理由见顶注 round5 那段)。 */
119
+ export function governanceAskMarksFor(scope) {
120
+ return createScopedView(processMarks, scope, DEFAULT_MAX_MARKS);
121
+ }
122
+ //# sourceMappingURL=governance-ask-marks.js.map
@@ -14,6 +14,21 @@ export declare const MAX_HOOK_MATCHER_GROUPS = 16;
14
14
  export declare const MAX_HOOK_HTTP_HEADERS = 32;
15
15
  /** hook 进程 stdout/stderr 各自的采集上限(字节)——防输出洪泛打爆内存/日志。 */
16
16
  export declare const MAX_HOOK_OUTPUT_BYTES: number;
17
+ /**
18
+ * 一次事件里所有 hook 上下文片段**聚合后**喂给模型的总量上限(字符)。
19
+ *
20
+ * 为什么单条帽不够:一次事件最多 {@link MAX_HOOK_ENTRIES_PER_EVENT} 条(parseHooksConfig 的广度闸),
21
+ * 每条最多推两段(`decision:"block"` 的 reason + `hookSpecificOutput.additionalContext`),
22
+ * {@link composeHooks} 再把部署槽与 task 槽的**成品**拼一次 —— 单条 4096 相乘后一次注入可达 ~256KB,
23
+ * 而这些字符是**逐次工具调用**进模型可见文本的(core `hooks.js` 的 preToolContext / `prepare-task.js`
24
+ * 的 tool_result 追加 / `runtask.js` 的 Stop follow-up),不是日志。
25
+ *
26
+ * 帽值取 10_000 的依据:模型可见文本在本栈的既有预算量纲就是 10k —— core `truncateError`
27
+ * (`src/core/tool-errors.ts`)的 10k 中截,逐字锚 CC 序列化器 `A7e` 的 `1e4`;#181 战役里 core 还要给
28
+ * `beforeToolCall` block reason 补一道同值兜底闸。取同一量级 = 「一次 hook 注入 ≤ 一份错误文本预算」,
29
+ * 且 4096 的单条帽保证**前两条**钩子的话仍整段进得去(截断是配置广度的代价,不是常态)。
30
+ */
31
+ export declare const MAX_HOOK_CONTEXT_TOTAL_CHARS = 10000;
17
32
  export interface ParseHooksConfigResult {
18
33
  config?: HooksConfig;
19
34
  /** 人可读的第一条校验错误(HTTP 层用它 400 fail-loud;resume 防御路径用它 warn)。 */
@@ -96,6 +111,19 @@ export type HookLlmCall = (opts: {
96
111
  /** 失败判别码(B8:判别一律走码,禁按 error 文案分支——v3.1 批2,统检第二波 high)。error 仍是给人看的
97
112
  * 自由文本;code 是给控制流的。缺席=未分类失败(不重试、不特判)。 */
98
113
  export type HookLlmFailureCode = "no_content" | "hard_timeout";
114
+ /**
115
+ * hook 上下文片段 → 喂给模型的单串。**所有** `additionalContext` 聚合点的唯一属主(此前是同一个
116
+ * `contexts.join("\n")` 表达式抄在二十余处,总量帽无处可挂 —— 那正是漂移成因)。
117
+ *
118
+ * 三条语义,都是承重的:
119
+ * 1. **顺序保留、只截尾**:先来的钩子先说话;一旦装不下就停,后面的条目一律不再挤进来(哪怕更短)。
120
+ * 按长度重排会让"第 3 条钩子的话"随别人的长度忽隐忽现,排障时无从复现。
121
+ * 2. **截断可见**:尾巴挂一行标记说明省了几条、丢了多少字符。静默丢弃会让 hook 作者以为自己的
122
+ * context 生效了,而模型那头根本没见过 —— 这类"以为配好了"的缺席比长文本本身更贵。
123
+ * 3. **标记不计预算**:与 core `truncateError` / CC `A7e` 同姿势(marker 不占那 10k),否则帽值的
124
+ * 含义会随标记文案长度漂。
125
+ */
126
+ export declare function buildHookContext(parts: readonly string[]): string | undefined;
99
127
  /**
100
128
  * 阶段三a:`http` 条目——契约语义 = POST hook 输入 JSON 到 `url`;headers 里的 `$NAME` 仅当 NAME 列在
101
129
  * `allowedEnvVars` 才从 worker 进程 env 插值(配置本身绝不携带密钥值,契约同边界)。
@@ -65,6 +65,24 @@ export const MAX_HOOK_HTTP_HEADERS = 32;
65
65
  export const MAX_HOOK_OUTPUT_BYTES = 1024 * 1024;
66
66
  /** 喂给模型的 deny 原因/additionalContext 的单条长度上限(字符)。 */
67
67
  const MAX_HOOK_FEEDBACK_CHARS = 4_096;
68
+ /**
69
+ * 一次事件里所有 hook 上下文片段**聚合后**喂给模型的总量上限(字符)。
70
+ *
71
+ * 为什么单条帽不够:一次事件最多 {@link MAX_HOOK_ENTRIES_PER_EVENT} 条(parseHooksConfig 的广度闸),
72
+ * 每条最多推两段(`decision:"block"` 的 reason + `hookSpecificOutput.additionalContext`),
73
+ * {@link composeHooks} 再把部署槽与 task 槽的**成品**拼一次 —— 单条 4096 相乘后一次注入可达 ~256KB,
74
+ * 而这些字符是**逐次工具调用**进模型可见文本的(core `hooks.js` 的 preToolContext / `prepare-task.js`
75
+ * 的 tool_result 追加 / `runtask.js` 的 Stop follow-up),不是日志。
76
+ *
77
+ * 帽值取 10_000 的依据:模型可见文本在本栈的既有预算量纲就是 10k —— core `truncateError`
78
+ * (`src/core/tool-errors.ts`)的 10k 中截,逐字锚 CC 序列化器 `A7e` 的 `1e4`;#181 战役里 core 还要给
79
+ * `beforeToolCall` block reason 补一道同值兜底闸。取同一量级 = 「一次 hook 注入 ≤ 一份错误文本预算」,
80
+ * 且 4096 的单条帽保证**前两条**钩子的话仍整段进得去(截断是配置广度的代价,不是常态)。
81
+ */
82
+ export const MAX_HOOK_CONTEXT_TOTAL_CHARS = 10_000;
83
+ /** 帽满时仍值得保留的最短残段(字符)。比这更短的尾巴不是信息:模型会把半句话读成完整指令,
84
+ * 宁可整条落进"省略"计数。 */
85
+ const MIN_HOOK_CONTEXT_PARTIAL_CHARS = 256;
68
86
  /** PostToolUse payload 里 tool_response 文本投影的上限(字符)——工具结果可能是 MB 级。 */
69
87
  const MAX_TOOL_RESPONSE_CHARS = 32_768;
70
88
  /** stdin payload 里 tool_input JSON 投影的上限(字符;巨大 tool_input 经 JSON.stringify 造成
@@ -233,6 +251,104 @@ const DEFAULT_AGENT_HOOK_TIMEOUT_SECONDS = 120;
233
251
  function clip(s, n) {
234
252
  return s.length > n ? `${s.slice(0, n)}…(${s.length})` : s;
235
253
  }
254
+ /**
255
+ * hook 上下文片段 → 喂给模型的单串。**所有** `additionalContext` 聚合点的唯一属主(此前是同一个
256
+ * `contexts.join("\n")` 表达式抄在二十余处,总量帽无处可挂 —— 那正是漂移成因)。
257
+ *
258
+ * 三条语义,都是承重的:
259
+ * 1. **顺序保留、只截尾**:先来的钩子先说话;一旦装不下就停,后面的条目一律不再挤进来(哪怕更短)。
260
+ * 按长度重排会让"第 3 条钩子的话"随别人的长度忽隐忽现,排障时无从复现。
261
+ * 2. **截断可见**:尾巴挂一行标记说明省了几条、丢了多少字符。静默丢弃会让 hook 作者以为自己的
262
+ * context 生效了,而模型那头根本没见过 —— 这类"以为配好了"的缺席比长文本本身更贵。
263
+ * 3. **标记不计预算**:与 core `truncateError` / CC `A7e` 同姿势(marker 不占那 10k),否则帽值的
264
+ * 含义会随标记文案长度漂。
265
+ */
266
+ export function buildHookContext(parts) {
267
+ const present = parts.filter((p) => p.length > 0).map(readHookContextPart);
268
+ if (present.length === 0)
269
+ return undefined;
270
+ const kept = [];
271
+ let used = 0;
272
+ let truncating = false;
273
+ let messages = 0;
274
+ let omitted = 0;
275
+ let droppedChars = 0;
276
+ for (const part of present) {
277
+ // 内层的账先并进来(标记读回,见 {@link readHookContextPart}),再谈这一轮自己丢了什么。
278
+ messages += part.messages;
279
+ omitted += part.omitted;
280
+ droppedChars += part.dropped;
281
+ if (!truncating) {
282
+ const sep = kept.length > 0 ? 1 : 0; // join 的 "\n" 也占预算
283
+ const room = MAX_HOOK_CONTEXT_TOTAL_CHARS - used - sep;
284
+ if (room >= part.body.length) {
285
+ if (part.body.length > 0) {
286
+ kept.push(part.body);
287
+ used += sep + part.body.length;
288
+ }
289
+ continue;
290
+ }
291
+ truncating = true;
292
+ // 只有**单条**发言可以切尾:切了它仍是"部分在场",账目诚实(dropped 记尾巴,omitted 不变)。
293
+ // 已聚合的成品代表内层多条、正文里却没有条目边界 —— 切一刀会把第 2..N 条整个抹掉却记 0 条省略,
294
+ // 标记恰好在最常走的两层合流路上说谎(codex 复审 R4,亲核属实)。⇒ 它要么整段留,要么整条丢。
295
+ if (part.messages === 1 && room >= MIN_HOOK_CONTEXT_PARTIAL_CHARS) {
296
+ kept.push(part.body.slice(0, room));
297
+ used += sep + room;
298
+ droppedChars += part.body.length - room;
299
+ continue;
300
+ }
301
+ }
302
+ // 整条不见 = 它代表的**全部**条目都不见。内层已经报过的那几条上面加过了,这里只补差额
303
+ // ——否则一个"3 of 5"的成品被整条丢掉会记成 8 条,报出"省掉的比总数还多"这种自相矛盾的账。
304
+ omitted += part.messages - part.omitted;
305
+ droppedChars += part.body.length;
306
+ }
307
+ const body = kept.join("\n");
308
+ if (droppedChars === 0)
309
+ return body;
310
+ const marker = `[hook context truncated at ${MAX_HOOK_CONTEXT_TOTAL_CHARS} chars: ${omitted} of ${messages} hook messages omitted, ${droppedChars} characters dropped]`;
311
+ return kept.length > 0 ? `${body}\n${marker}` : marker;
312
+ }
313
+ /** 截断标记的尾锚形。帽值**逐字写进模式**(不是 `\d+`):只认"我们自己这一版才会写出的那一份",
314
+ * 别处飘来的同类文字连模式都不匹配。 */
315
+ const HOOK_CONTEXT_MARKER_RE = new RegExp(`\\n\\[hook context truncated at ${MAX_HOOK_CONTEXT_TOTAL_CHARS} chars: (\\d+) of (\\d+) hook messages omitted, (\\d+) characters dropped\\]$`);
316
+ /** 读回标记时接受的数字上界(2^42)。比任何真值大若干数量级,又小到几十条相加也绝不越过安全整数
317
+ * —— 伪造的巨数因此根本进不来,合并加法也不必每一步复验溢出。 */
318
+ const MAX_HOOK_CONTEXT_PARSED_COUNT = 2 ** 42;
319
+ /**
320
+ * 片段 → 内容 + 它自带的截断账。
321
+ *
322
+ * 为什么必须能**读回**自己写的标记:`composeHooks` 会把 {@link buildHookContext} 的成品当片段再喂一次
323
+ * (部署槽在场即走这条路 —— 是常态不是边角)。标记按内容处理会犯两桩:①切在标记中间 ⇒ 模型读到半截
324
+ * 标记;②外层重新记账 ⇒ 内层丢掉的那一万多字符凭空消失,标记数字变成谎。标记既然不计预算,读回时也
325
+ * 不能算内容;把它携带的账并进外层,函数对自己的输出就是幂等的。
326
+ *
327
+ * 🔴 这把尺子对着的是**不可信文本**(钩子想吐什么就吐什么),所以只认"我们自己才写得出的那一份":
328
+ * 帽值逐字相等(在模式里)、`dropped ≥ 1`(我们从不在零丢弃时写标记)、`omitted ≤ messages`、
329
+ * `messages ≥ 1`、数字都在 {@link MAX_HOOK_CONTEXT_PARSED_COUNT} 内。任一条不合 ⇒ **整段当普通内容**。
330
+ * 认下之后也不会有"内容静默少一行":认下 ⇒ `dropped > 0` ⇒ 输出必然带标记 —— 摘掉的那一行总是被
331
+ * **重写**回去(数字可能被合并),从不是被删掉。伪造得刚好合规只会让记账口径偏,不改任何裁决;
332
+ * 而且钩子配置在单用户闸内,伪造者就是这份账的读者本人。
333
+ */
334
+ function readHookContextPart(part) {
335
+ const m = HOOK_CONTEXT_MARKER_RE.exec(part);
336
+ if (!m)
337
+ return { body: part, messages: 1, omitted: 0, dropped: 0 };
338
+ const omitted = Number(m[1]);
339
+ const messages = Number(m[2]);
340
+ const dropped = Number(m[3]);
341
+ const sane = (n) => Number.isSafeInteger(n) && n <= MAX_HOOK_CONTEXT_PARSED_COUNT;
342
+ if (!sane(omitted) || !sane(messages) || !sane(dropped) || messages < 1 || omitted > messages || dropped < 1) {
343
+ return { body: part, messages: 1, omitted: 0, dropped: 0 };
344
+ }
345
+ return { body: part.slice(0, part.length - m[0].length), messages, omitted, dropped };
346
+ }
347
+ /** {@link buildHookContext} 的字段形(聚合点全是 `...spread` 进结果对象):空 → 不产字段。 */
348
+ function buildHookContextField(parts) {
349
+ const text = buildHookContext(parts);
350
+ return text === undefined ? {} : { additionalContext: text };
351
+ }
236
352
  /** 跑一条 command hook:sh -c,stdin=payload JSON(CC-verbatim),超时 SIGKILL,输出各限 1MiB。 */
237
353
  function runCommandHook(entry, payload, ctx) {
238
354
  return new Promise((resolve) => {
@@ -736,7 +852,7 @@ export function createTaskHooks(config, ctx) {
736
852
  if (run.code === 2) {
737
853
  // CC:exit 2 = 阻断,stderr 喂给模型
738
854
  const reason = clip(run.stderr.trim(), MAX_HOOK_FEEDBACK_CHARS) || "blocked by a PreToolUse hook";
739
- return { action: "deny", message: reason, ...(contexts.length ? { additionalContext: contexts.join("\n") } : {}) };
855
+ return { action: "deny", message: reason, ...buildHookContextField(contexts) };
740
856
  }
741
857
  if (run.code !== 0) {
742
858
  ctx.logger.warn("hook_command_failed", { event: "PreToolUse", code: run.code, stderr: clip(run.stderr, 300) });
@@ -750,11 +866,11 @@ export function createTaskHooks(config, ctx) {
750
866
  if (out.continue === false) {
751
867
  const reason = clip(typeof out.stopReason === "string" ? out.stopReason : "hook requested stop", MAX_HOOK_FEEDBACK_CHARS);
752
868
  // core 的 preToolUse seam 无"终止整个 run"能力(阶段一诚实近似:deny 本次调用)。
753
- return { action: "deny", message: reason, ...(contexts.length ? { additionalContext: contexts.join("\n") } : {}) };
869
+ return { action: "deny", message: reason, ...buildHookContextField(contexts) };
754
870
  }
755
871
  if (out.decision === "block") {
756
872
  const reason = clip(typeof out.reason === "string" ? out.reason : "blocked by a PreToolUse hook", MAX_HOOK_FEEDBACK_CHARS);
757
- return { action: "deny", message: reason, ...(contexts.length ? { additionalContext: contexts.join("\n") } : {}) };
873
+ return { action: "deny", message: reason, ...buildHookContextField(contexts) };
758
874
  }
759
875
  const hso = out.hookSpecificOutput;
760
876
  if (hso !== null && typeof hso === "object" && hso.hookEventName === "PreToolUse") {
@@ -764,7 +880,7 @@ export function createTaskHooks(config, ctx) {
764
880
  const decision = h.permissionDecision;
765
881
  if (decision === "deny") {
766
882
  const reason = clip(typeof h.permissionDecisionReason === "string" ? h.permissionDecisionReason : "denied by a PreToolUse hook", MAX_HOOK_FEEDBACK_CHARS);
767
- return { action: "deny", message: reason, ...(contexts.length ? { additionalContext: contexts.join("\n") } : {}) };
883
+ return { action: "deny", message: reason, ...buildHookContextField(contexts) };
768
884
  }
769
885
  if (decision === "ask") {
770
886
  asked = clip(typeof h.permissionDecisionReason === "string" ? h.permissionDecisionReason : "a PreToolUse hook requires approval", MAX_HOOK_FEEDBACK_CHARS);
@@ -798,7 +914,7 @@ export function createTaskHooks(config, ctx) {
798
914
  action: "ask",
799
915
  message: asked,
800
916
  ...(rewritten ? { updatedInput: currentInput } : {}),
801
- ...(contexts.length ? { additionalContext: contexts.join("\n") } : {}),
917
+ ...buildHookContextField(contexts),
802
918
  };
803
919
  }
804
920
  if (unknownDecision) {
@@ -814,14 +930,14 @@ export function createTaskHooks(config, ctx) {
814
930
  action: "ask",
815
931
  message: "a PreToolUse hook returned an unrecognized permissionDecision — approval required",
816
932
  ...(rewritten ? { updatedInput: currentInput } : {}),
817
- ...(contexts.length ? { additionalContext: contexts.join("\n") } : {}),
933
+ ...buildHookContextField(contexts),
818
934
  };
819
935
  }
820
936
  if (rewritten || contexts.length > 0) {
821
937
  return {
822
938
  action: "allow",
823
939
  ...(rewritten ? { updatedInput: currentInput } : {}),
824
- ...(contexts.length ? { additionalContext: contexts.join("\n") } : {}),
940
+ ...buildHookContextField(contexts),
825
941
  };
826
942
  }
827
943
  return undefined; // 无意见(≠ allow 短路;core 门照常裁决)
@@ -879,7 +995,8 @@ export function createTaskHooks(config, ctx) {
879
995
  }
880
996
  }
881
997
  }
882
- return contexts.length > 0 ? { additionalContext: contexts.join("\n") } : undefined;
998
+ const additionalContext = buildHookContext(contexts);
999
+ return additionalContext === undefined ? undefined : { additionalContext };
883
1000
  };
884
1001
  hooks.postToolUse = postToolUseHook;
885
1002
  }
@@ -927,7 +1044,8 @@ export function createTaskHooks(config, ctx) {
927
1044
  contexts.push(clip(h.additionalContext, MAX_HOOK_FEEDBACK_CHARS));
928
1045
  }
929
1046
  }
930
- return contexts.length > 0 ? { additionalContext: contexts.join("\n") } : undefined;
1047
+ const additionalContext = buildHookContext(contexts);
1048
+ return additionalContext === undefined ? undefined : { additionalContext };
931
1049
  };
932
1050
  hooks.postToolUseFailure = postToolUseFailureHook;
933
1051
  }
@@ -973,7 +1091,8 @@ export function createTaskHooks(config, ctx) {
973
1091
  if (out.hookSpecificOutput !== undefined)
974
1092
  ctx.logger.warn("hook_result_unsupported", { event: "PostToolBatch" });
975
1093
  }
976
- return contexts.length > 0 ? { additionalContext: contexts.join("\n") } : undefined;
1094
+ const additionalContext = buildHookContext(contexts);
1095
+ return additionalContext === undefined ? undefined : { additionalContext };
977
1096
  };
978
1097
  hooks.postToolBatch = postToolBatchHook;
979
1098
  }
@@ -989,7 +1108,7 @@ export function createTaskHooks(config, ctx) {
989
1108
  for (const { run } of singles) {
990
1109
  if (run.code === 2) {
991
1110
  const reason = clip(run.stderr.trim(), MAX_HOOK_FEEDBACK_CHARS) || "blocked by a UserPromptSubmit hook";
992
- return { block: reason, ...(contexts.length ? { additionalContext: contexts.join("\n") } : {}) };
1111
+ return { block: reason, ...buildHookContextField(contexts) };
993
1112
  }
994
1113
  if (run.code !== 0) {
995
1114
  ctx.logger.warn("hook_command_failed", { event: "UserPromptSubmit", code: run.code, stderr: clip(run.stderr, 300) });
@@ -1003,11 +1122,11 @@ export function createTaskHooks(config, ctx) {
1003
1122
  if (out.continue === false) {
1004
1123
  // continue:false=终止整个 run;userPromptSubmit 的 block 语义正是"任务带理由收场"——同义直映射。
1005
1124
  const reason = clip(typeof out.stopReason === "string" ? out.stopReason : "hook requested stop", MAX_HOOK_FEEDBACK_CHARS);
1006
- return { block: reason, ...(contexts.length ? { additionalContext: contexts.join("\n") } : {}) };
1125
+ return { block: reason, ...buildHookContextField(contexts) };
1007
1126
  }
1008
1127
  if (out.decision === "block") {
1009
1128
  const reason = clip(typeof out.reason === "string" ? out.reason : "blocked by a UserPromptSubmit hook", MAX_HOOK_FEEDBACK_CHARS);
1010
- return { block: reason, ...(contexts.length ? { additionalContext: contexts.join("\n") } : {}) };
1129
+ return { block: reason, ...buildHookContextField(contexts) };
1011
1130
  }
1012
1131
  const hso = out.hookSpecificOutput;
1013
1132
  if (hso !== null && typeof hso === "object" && hso.hookEventName === "UserPromptSubmit") {
@@ -1016,7 +1135,8 @@ export function createTaskHooks(config, ctx) {
1016
1135
  contexts.push(clip(h.additionalContext, MAX_HOOK_FEEDBACK_CHARS));
1017
1136
  }
1018
1137
  }
1019
- return contexts.length > 0 ? { additionalContext: contexts.join("\n") } : undefined;
1138
+ const additionalContext = buildHookContext(contexts);
1139
+ return additionalContext === undefined ? undefined : { additionalContext };
1020
1140
  };
1021
1141
  hooks.userPromptSubmit = userPromptSubmitHook;
1022
1142
  }
@@ -1180,7 +1300,7 @@ export function createTaskHooks(config, ctx) {
1180
1300
  return undefined;
1181
1301
  return {
1182
1302
  ...(block !== undefined ? { block } : {}),
1183
- ...(contexts.length ? { additionalContext: contexts.join("\n") } : {}),
1303
+ ...buildHookContextField(contexts),
1184
1304
  };
1185
1305
  };
1186
1306
  hooks.stop = stopHook;
@@ -1288,7 +1408,7 @@ export function composeHooks(deployment, task) {
1288
1408
  if (r.additionalContext)
1289
1409
  contexts.push(r.additionalContext);
1290
1410
  if (r.action === "deny")
1291
- return { ...r, ...(contexts.length ? { additionalContext: contexts.join("\n") } : {}) };
1411
+ return { ...r, ...buildHookContextField(contexts) };
1292
1412
  if (r.action === "ask")
1293
1413
  asked = r;
1294
1414
  if (r.action === "allow" && r.updatedInput !== undefined) {
@@ -1301,14 +1421,14 @@ export function composeHooks(deployment, task) {
1301
1421
  return {
1302
1422
  ...asked,
1303
1423
  ...(askedUpdated === undefined && current !== input ? { updatedInput: current } : {}),
1304
- ...(contexts.length ? { additionalContext: contexts.join("\n") } : {}),
1424
+ ...buildHookContextField(contexts),
1305
1425
  };
1306
1426
  }
1307
1427
  if (current !== input || contexts.length > 0) {
1308
1428
  return {
1309
1429
  action: "allow",
1310
1430
  ...(current !== input ? { updatedInput: current } : {}),
1311
- ...(contexts.length ? { additionalContext: contexts.join("\n") } : {}),
1431
+ ...buildHookContextField(contexts),
1312
1432
  };
1313
1433
  }
1314
1434
  return undefined;
@@ -1324,7 +1444,7 @@ export function composeHooks(deployment, task) {
1324
1444
  const updatedOutput = rt?.updatedOutput ?? rd?.updatedOutput;
1325
1445
  return {
1326
1446
  ...(updatedOutput ? { updatedOutput } : {}),
1327
- ...(contexts.length ? { additionalContext: contexts.join("\n") } : {}),
1447
+ ...buildHookContextField(contexts),
1328
1448
  };
1329
1449
  };
1330
1450
  }
@@ -1333,7 +1453,8 @@ export function composeHooks(deployment, task) {
1333
1453
  const rd = d.postToolUseFailure ? await d.postToolUseFailure(toolName, input, failure, ctx) : undefined;
1334
1454
  const rt = t.postToolUseFailure ? await t.postToolUseFailure(toolName, input, failure, ctx) : undefined;
1335
1455
  const contexts = [rd?.additionalContext, rt?.additionalContext].filter((c) => typeof c === "string" && c.length > 0);
1336
- return contexts.length ? { additionalContext: contexts.join("\n") } : undefined;
1456
+ const additionalContext = buildHookContext(contexts);
1457
+ return additionalContext === undefined ? undefined : { additionalContext };
1337
1458
  };
1338
1459
  }
1339
1460
  if (d.postToolBatch || t.postToolBatch) {
@@ -1341,19 +1462,22 @@ export function composeHooks(deployment, task) {
1341
1462
  const rd = d.postToolBatch ? await d.postToolBatch(calls) : undefined;
1342
1463
  const rt = t.postToolBatch ? await t.postToolBatch(calls) : undefined;
1343
1464
  const contexts = [rd?.additionalContext, rt?.additionalContext].filter((c) => typeof c === "string" && c.length > 0);
1344
- return contexts.length ? { additionalContext: contexts.join("\n") } : undefined;
1465
+ const additionalContext = buildHookContext(contexts);
1466
+ return additionalContext === undefined ? undefined : { additionalContext };
1345
1467
  };
1346
1468
  }
1347
1469
  if (d.userPromptSubmit || t.userPromptSubmit) {
1348
1470
  out.userPromptSubmit = async (prompt) => {
1349
1471
  const rd = d.userPromptSubmit ? await d.userPromptSubmit(prompt) : undefined;
1472
+ // 短路的是**槽序**(部署拦下就不再跑 task 槽),不是预算:`additionalContext` 与 `block` 在 core
1473
+ // 是解耦两字段(见下方 stop 注),原样外送就是一条绕过总帽的道。
1350
1474
  if (rd?.block)
1351
- return rd;
1475
+ return { ...rd, ...buildHookContextField(rd.additionalContext ? [rd.additionalContext] : []) };
1352
1476
  const rt = t.userPromptSubmit ? await t.userPromptSubmit(prompt) : undefined;
1353
1477
  if (!rd && !rt)
1354
1478
  return undefined;
1355
1479
  const contexts = [rd?.additionalContext, rt?.additionalContext].filter((c) => typeof c === "string" && c.length > 0);
1356
- return { ...(rt?.block ? { block: rt.block } : {}), ...(contexts.length ? { additionalContext: contexts.join("\n") } : {}) };
1480
+ return { ...(rt?.block ? { block: rt.block } : {}), ...buildHookContextField(contexts) };
1357
1481
  };
1358
1482
  }
1359
1483
  if (d.stop || t.stop) {
@@ -1362,12 +1486,12 @@ export function composeHooks(deployment, task) {
1362
1486
  out.stop = async (sctx) => {
1363
1487
  const rd = d.stop ? await d.stop(sctx) : undefined;
1364
1488
  if (rd?.block)
1365
- return rd;
1489
+ return { ...rd, ...buildHookContextField(rd.additionalContext ? [rd.additionalContext] : []) };
1366
1490
  const rt = t.stop ? await t.stop(sctx) : undefined;
1367
1491
  if (!rd && !rt)
1368
1492
  return undefined;
1369
1493
  const contexts = [rd?.additionalContext, rt?.additionalContext].filter((c) => typeof c === "string" && c.length > 0);
1370
- return { ...(rt?.block ? { block: rt.block } : {}), ...(contexts.length ? { additionalContext: contexts.join("\n") } : {}) };
1494
+ return { ...(rt?.block ? { block: rt.block } : {}), ...buildHookContextField(contexts) };
1371
1495
  };
1372
1496
  }
1373
1497
  if (d.preCompact || t.preCompact) {
@@ -1,10 +1,10 @@
1
- import { principalFrom, decodeCheckpointScope } from "../../security.js";
1
+ import { principalFrom, decodeCheckpointScope, PRINCIPAL_TOKEN_HEADER, APPROVAL_MAC_HEADER, APPROVAL_MAC_KID_HEADER } from "../../security.js";
2
2
  import { verifyDirectDoorProof } from "../../principal-jwt.js";
3
3
  import { MAX_APPROVAL_REASON_CHARS } from "../../approval-hmac.js";
4
4
  import { redactedPreview } from "../../trace/redact.js";
5
5
  import { fleetRunLabels } from "../../fleet/fleet-bus.js"; // [2069]④ §3 行展示名与 fleet 行同源(见用处的注)
6
6
  import { sleep } from "../sse-log.js";
7
- import { sendJson, sendError, sseHeaders } from "../send.js";
7
+ import { sendJson, sendError, sseHeaders, SSE_MAX_STREAM_MS, SSE_HEARTBEAT_IDLE_MS } from "../send.js";
8
8
  import { gatedPrincipal, explicitOperatorOk, isOperator } from "../principal-gate.js";
9
9
  // design/80 seam #2 (assistant-scheduler): graceful preempt (durable yield) + resource_limit resume of one task.
10
10
  export const ASSISTANT_PREEMPT_RE = /^\/v1\/assistant\/tasks\/([^/]+)\/preempt$/;
@@ -427,7 +427,7 @@ async function handleApprovalsAssistantBody(req, res, url, ctx, miss) {
427
427
  let deciderPrincipal = principal;
428
428
  if (deps.config.directDoorActive) {
429
429
  const hdr = (n) => { const h = req.headers[n]; return Array.isArray(h) ? h[0] : h; };
430
- const proof = verifyDirectDoorProof({ jwt: hdr("x-approval-principal-token"), mac: hdr("x-approval-mac"), kid: hdr("x-approval-mac-kid") },
430
+ const proof = verifyDirectDoorProof({ jwt: hdr(PRINCIPAL_TOKEN_HEADER), mac: hdr(APPROVAL_MAC_HEADER), kid: hdr(APPROVAL_MAC_KID_HEADER) },
431
431
  // reason 入签:同一条规则也适用于身份绑定腿(两个绑定位为 null,第五位照常参与)。
432
432
  { sessionId: run.sessionId, decision, reason: typeof body.reason === "string" ? body.reason : null }, deps.config, { actionBinding: false });
433
433
  if (!proof.ok) {
@@ -569,7 +569,7 @@ async function handleApprovalsAssistantBody(req, res, url, ctx, miss) {
569
569
  const h = req.headers[n];
570
570
  return Array.isArray(h) ? h[0] : h;
571
571
  };
572
- const proof = verifyDirectDoorProof({ jwt: hdr("x-approval-principal-token"), mac: hdr("x-approval-mac"), kid: hdr("x-approval-mac-kid") },
572
+ const proof = verifyDirectDoorProof({ jwt: hdr(PRINCIPAL_TOKEN_HEADER), mac: hdr(APPROVAL_MAC_HEADER), kid: hdr(APPROVAL_MAC_KID_HEADER) },
573
573
  // reason 入签(三方裁 (a) + clay 干净切):落账理由属于决定内容,与 decision 同受完整性保护。
574
574
  { sessionId, boundCallId: binding.boundCallId, boundInputHash: binding.boundInputHash, decision, reason: typeof body.reason === "string" ? body.reason : null }, deps.config);
575
575
  if (!proof.ok) {
@@ -682,7 +682,6 @@ export async function streamApprovals(req, res, cs, scope, pollMs = APPROVALS_ST
682
682
  let closed = false;
683
683
  req.on("close", () => { closed = true; });
684
684
  const start = Date.now();
685
- const MAX_MS = 15 * 60 * 1000;
686
685
  let lastBeat = Date.now();
687
686
  let prev = new Map();
688
687
  let first = true;
@@ -715,11 +714,11 @@ export async function streamApprovals(req, res, cs, scope, pollMs = APPROVALS_ST
715
714
  res.write(`event: resolved\ndata: ${JSON.stringify({ type: "resolved", sessionId: p.sessionId, toolCallId: p.toolCallId })}\n\n`); // decided/expired/gone
716
715
  }
717
716
  prev = cur;
718
- if (Date.now() - start > MAX_MS) {
717
+ if (Date.now() - start > SSE_MAX_STREAM_MS) {
719
718
  res.write(`event: error\ndata: ${JSON.stringify({ type: "error", errorCode: "STREAM_MAX_DURATION", message: "approvals stream reached its 15-minute cap — reconnect to continue" })}\n\n`);
720
719
  break;
721
720
  }
722
- if (Date.now() - lastBeat > 15_000) {
721
+ if (Date.now() - lastBeat > SSE_HEARTBEAT_IDLE_MS) {
723
722
  res.write(`event: heartbeat\ndata: ${JSON.stringify({ type: "heartbeat" })}\n\n`);
724
723
  lastBeat = Date.now();
725
724
  }
@@ -58,14 +58,19 @@ export function buildStaticWiringAudit(facts) {
58
58
  // park 车道的**持久性读数**(codex 交叉复审 2026-08-07 R2-F2,验真后部分采纳)。`capable` 说的是
59
59
  // 「有没有 checkpoint 店」、`effective` 说的是「这条部署形会不会真 park」—— 两者都为真时仍可能是一个
60
60
  // 重启即丢的店,那样窗到期后被 park 的审批在重启后无法赎回,而流内审批协议正是拿 park 当降级目的地。
61
- // 🔴 为什么是 **warn 而不是拒启**:本仓的 SQL/local checkpoint 店**都还没有 `durability` 声明**,core
62
- // 因此对每一个真持久部署都读出 `process_local`。据此拒启会拿一个不可信信号挡住 100% 的现存 durable
63
- // 部署 —— 那不是查出矛盾。根治在店侧补声明;在那之前,把「这个读数不可信」响亮说出来,不静默。
61
+ // **读数已转可信**(#168 件5,2026-08-07):树上三条 checkpoint 店(SQL 双生 + local 文件形)现在都
62
+ // 如实声明了 `durability`,所以这条读数不再对真持久部署恒假 —— 它现在说的就是字面意思。
63
+ //
64
+ // 🔴 仍然是 **warn 而不是拒启**,判据与 session 那条**刻意不同**,理由写在这里免得下一个人以为是漏改:
65
+ // session 那条对表的是「声明 durable × 介质不 durable」——两个已知量互相矛盾,拒启是查出了谎。这一条
66
+ // 对表的是「有没有人声明」,而 core 把**缺席**折成 `process-local`;一个合法的第三方/新增 checkpoint
67
+ // 店没表态时,拒启会挡住一个本来跑得好好的部署,而**没有任何旋钮**能让它继续启动(补偿为零 ⇒ 不升格)。
68
+ // 升格的前置件 = core 把「未声明」与「声明为进程内」分成两个可区分读数,或本仓给出一个显式承认旋钮。
64
69
  if (facts.parkFacility && facts.manifest.parkLane.checkpointDurability !== "declared_durable") {
65
70
  warnings.push(`park facility is enabled but the checkpoint store does not declare durable storage ` +
66
71
  `(parkLane.checkpointDurability=${JSON.stringify(facts.manifest.parkLane.checkpointDurability ?? null)}). ` +
67
- `A parked approval survives a restart only on a durable store and the in-tree checkpoint adapters currently declare no durability at all, so this reading cannot yet distinguish "really process-local" from "nobody declared it". ` +
68
- `Treat park-lane recovery as unproven until the adapter declares its durability.`);
72
+ `A parked approval survives a restart only on a durable store, and this reading folds "declared process-local" together with "declared nothing" every in-tree adapter declares "durable", so a non-durable reading here means a store this build does not own. ` +
73
+ `Treat park-lane recovery as unproven until that adapter declares its durability.`);
69
74
  }
70
75
  if (facts.manifest.interaction.posture === "interactive" && facts.manifest.question.wired !== "wired") {
71
76
  warnings.push(`interactionPosture declares "interactive" but no question delivery face is wired (question.wired=${JSON.stringify(facts.manifest.question.wired)}) — ` +