@sema-agent/server 7.132.0 → 7.133.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 (402) hide show
  1. package/MIGRATION.md +266 -0
  2. package/README.md +8 -9
  3. package/README.zh-CN.md +7 -6
  4. package/USAGE.md +174 -55
  5. package/deploy/sema-up/README.md +19 -1
  6. package/deploy/sema-up/chart/templates/server-v4pro.yaml +5 -2
  7. package/deploy/sema-up/chart/templates/server.yaml +5 -2
  8. package/deploy/sema-up/stack/compose.yaml +3 -0
  9. package/deploy/sema-up/stack/sema-stack-cluster.yaml +4 -2
  10. package/dist/adoption/plan.d.ts +3 -2
  11. package/dist/adoption/plan.js +7 -5
  12. package/dist/adoption/runner.js +3 -10
  13. package/dist/adoption/sql.d.ts +3 -2
  14. package/dist/adoption/sql.js +3 -5
  15. package/dist/adoption/wire.d.ts +4 -1
  16. package/dist/adoption/wire.js +1 -0
  17. package/dist/approval-ask-audit-store.d.ts +31 -13
  18. package/dist/approval-ask-audit-store.js +272 -74
  19. package/dist/approval-ask-machine.d.ts +15 -0
  20. package/dist/approval-ask-machine.js +6 -0
  21. package/dist/approval-ask-retention.d.ts +136 -0
  22. package/dist/approval-ask-retention.js +102 -0
  23. package/dist/approval-card.d.ts +79 -19
  24. package/dist/approval-card.js +44 -13
  25. package/dist/approval-deny-reasons.d.ts +1 -1
  26. package/dist/approval.d.ts +9 -0
  27. package/dist/approval.js +24 -3
  28. package/dist/audit.d.ts +27 -10
  29. package/dist/audit.js +12 -16
  30. package/dist/bake-runner/main.js +3 -2
  31. package/dist/boot/deferred-sandbox-path-env.js +1 -1
  32. package/dist/boot/execution-env.js +9 -7
  33. package/dist/boot/execution-lane.js +11 -0
  34. package/dist/boot/leader.js +2 -0
  35. package/dist/boot/memory-boundary.js +2 -2
  36. package/dist/boot/memory-consolidation.d.ts +5 -4
  37. package/dist/boot/memory-consolidation.js +7 -6
  38. package/dist/boot/org-delivery.d.ts +28 -0
  39. package/dist/boot/org-delivery.js +20 -0
  40. package/dist/boot/reapers.d.ts +3 -0
  41. package/dist/boot/reapers.js +21 -1
  42. package/dist/boot/resolve-spec.d.ts +3 -0
  43. package/dist/boot/resolve-spec.js +60 -81
  44. package/dist/boot/runner-deps.js +1 -0
  45. package/dist/boot/session-faces.d.ts +1 -0
  46. package/dist/boot/session-faces.js +4 -2
  47. package/dist/boot/stage-02-web-admin-pull.js +4 -1
  48. package/dist/boot/stage-06-runners.d.ts +2 -0
  49. package/dist/boot/stage-06-runners.js +6 -3
  50. package/dist/boot/stage-07-capability-layer.d.ts +2 -0
  51. package/dist/boot/stage-07-capability-layer.js +24 -3
  52. package/dist/boot/stage-08-reapers.d.ts +2 -0
  53. package/dist/boot/stage-08-reapers.js +2 -2
  54. package/dist/boot/stage-09-leader.d.ts +3 -0
  55. package/dist/boot/stage-10-http-server.d.ts +3 -0
  56. package/dist/boot/stage-10-http-server.js +5 -6
  57. package/dist/boot/stores.js +2 -2
  58. package/dist/boot/web-admin.d.ts +4 -0
  59. package/dist/boot/web-admin.js +151 -63
  60. package/dist/boot/workflow-orchestration.d.ts +2 -0
  61. package/dist/boot/workflow-orchestration.js +2 -1
  62. package/dist/brain.js +8 -3
  63. package/dist/breaker-key.d.ts +6 -0
  64. package/dist/breaker-key.js +29 -0
  65. package/dist/caller-text-echo.d.ts +9 -6
  66. package/dist/caller-text-echo.js +8 -11
  67. package/dist/capabilities/center-plugins.d.ts +7 -0
  68. package/dist/capabilities/center-plugins.js +1 -1
  69. package/dist/capabilities/composite-members.d.ts +1 -1
  70. package/dist/capabilities/composite-members.js +1 -1
  71. package/dist/capabilities/execution-lane.js +2 -2
  72. package/dist/capabilities/hands-lane.d.ts +21 -0
  73. package/dist/capabilities/hands-lane.js +10 -0
  74. package/dist/capabilities/mcp-probe.d.ts +36 -4
  75. package/dist/capabilities/mcp-probe.js +27 -4
  76. package/dist/capabilities/scenarios.d.ts +63 -7
  77. package/dist/capabilities/scenarios.js +39 -7
  78. package/dist/capability-facts.d.ts +34 -4
  79. package/dist/capability-facts.js +28 -2
  80. package/dist/center-config-tier.d.ts +69 -0
  81. package/dist/center-config-tier.js +42 -0
  82. package/dist/checkpoint-identity.d.ts +16 -0
  83. package/dist/checkpoint-identity.js +19 -0
  84. package/dist/config-catalog.d.ts +17 -10
  85. package/dist/config-catalog.js +43 -28
  86. package/dist/config-d-migration.d.ts +3 -1
  87. package/dist/config-d-migration.js +2 -2
  88. package/dist/config-dir-trust.d.ts +114 -0
  89. package/dist/config-dir-trust.js +388 -0
  90. package/dist/config-invariants.d.ts +18 -5
  91. package/dist/config-invariants.js +35 -3
  92. package/dist/config-provider.d.ts +6 -1
  93. package/dist/config-provider.js +6 -2
  94. package/dist/config-types.d.ts +71 -14
  95. package/dist/config-types.js +8 -0
  96. package/dist/config.d.ts +13 -0
  97. package/dist/config.js +121 -59
  98. package/dist/delegated-session-owner.d.ts +185 -0
  99. package/dist/delegated-session-owner.js +119 -0
  100. package/dist/device-protocol.d.ts +5 -3
  101. package/dist/device-protocol.js +1 -1
  102. package/dist/device-ws-hub.js +21 -2
  103. package/dist/device-ws-protocol.d.ts +70 -6
  104. package/dist/device-ws-protocol.js +50 -23
  105. package/dist/elicitation.js +3 -2
  106. package/dist/entry-problems.d.ts +34 -0
  107. package/dist/entry-problems.js +14 -0
  108. package/dist/execution-lane-caps.d.ts +49 -10
  109. package/dist/execution-lane-caps.js +43 -4
  110. package/dist/field-conflict.d.ts +43 -0
  111. package/dist/field-conflict.js +20 -0
  112. package/dist/fleet/fleet-bus.d.ts +80 -6
  113. package/dist/fleet/fleet-bus.js +41 -9
  114. package/dist/hooks/classic-stations.d.ts +10 -5
  115. package/dist/hooks/classic-stations.js +4 -4
  116. package/dist/hooks/command-spawn.d.ts +57 -0
  117. package/dist/hooks/command-spawn.js +139 -0
  118. package/dist/hooks/hook-llm.js +2 -2
  119. package/dist/hooks/hook-runner.d.ts +127 -25
  120. package/dist/hooks/hook-runner.js +668 -287
  121. package/dist/hooks/hook-source.d.ts +44 -0
  122. package/dist/hooks/hook-source.js +40 -0
  123. package/dist/http/active-run-conflict.d.ts +5 -13
  124. package/dist/http/active-run-conflict.js +17 -27
  125. package/dist/http/admission.js +103 -60
  126. package/dist/http/approval-sweeps.js +55 -18
  127. package/dist/http/caller-trust.d.ts +77 -0
  128. package/dist/http/caller-trust.js +143 -0
  129. package/dist/http/checkpoint-ctx.d.ts +16 -16
  130. package/dist/http/checkpoint-ctx.js +13 -10
  131. package/dist/http/claimed-run.d.ts +4 -3
  132. package/dist/http/device-placement.d.ts +2 -4
  133. package/dist/http/device-placement.js +2 -1
  134. package/dist/http/dispatch.js +10 -4
  135. package/dist/http/durable-run-leg.d.ts +4 -32
  136. package/dist/http/durable-run-leg.js +34 -10
  137. package/dist/http/owner-access.d.ts +56 -0
  138. package/dist/http/owner-access.js +42 -0
  139. package/dist/http/principal-gate.d.ts +48 -23
  140. package/dist/http/principal-gate.js +39 -12
  141. package/dist/http/readiness.d.ts +11 -5
  142. package/dist/http/readiness.js +10 -3
  143. package/dist/http/request-guards.d.ts +16 -23
  144. package/dist/http/request-guards.js +30 -15
  145. package/dist/http/resume-ctx-read.d.ts +2 -5
  146. package/dist/http/resume-ctx-read.js +7 -4
  147. package/dist/http/resume-legs.d.ts +3 -4
  148. package/dist/http/resume-legs.js +126 -69
  149. package/dist/http/retired-service-deps.d.ts +43 -0
  150. package/dist/http/retired-service-deps.js +18 -0
  151. package/dist/http/route-ctx.d.ts +44 -28
  152. package/dist/http/route-ctx.js +4 -0
  153. package/dist/http/route-table.js +2 -0
  154. package/dist/http/routes/a2a-serve.js +3 -4
  155. package/dist/http/routes/admin-config-refresh.js +1 -1
  156. package/dist/http/routes/admin-drain.js +1 -1
  157. package/dist/http/routes/admin-memory-optout.js +5 -1
  158. package/dist/http/routes/adoption.js +2 -9
  159. package/dist/http/routes/approvals-assistant.js +49 -48
  160. package/dist/http/routes/background.js +3 -2
  161. package/dist/http/routes/capabilities.js +20 -4
  162. package/dist/http/routes/config-catalog.d.ts +3 -3
  163. package/dist/http/routes/config-catalog.js +1 -1
  164. package/dist/http/routes/devices.js +3 -17
  165. package/dist/http/routes/diagnostics.js +5 -1
  166. package/dist/http/routes/images.js +6 -7
  167. package/dist/http/routes/leader.js +1 -8
  168. package/dist/http/routes/mcp-reconnect.d.ts +1 -1
  169. package/dist/http/routes/mcp-reconnect.js +3 -2
  170. package/dist/http/routes/memory-bundle.js +1 -1
  171. package/dist/http/routes/memory-compliance.js +1 -1
  172. package/dist/http/routes/memory-consolidation.js +1 -1
  173. package/dist/http/routes/memory-origin.js +1 -1
  174. package/dist/http/routes/memory-policy.d.ts +1 -1
  175. package/dist/http/routes/memory-policy.js +3 -2
  176. package/dist/http/routes/notify-wake.js +8 -21
  177. package/dist/http/routes/observability.js +2 -2
  178. package/dist/http/routes/principal-inbox.d.ts +39 -0
  179. package/dist/http/routes/principal-inbox.js +76 -0
  180. package/dist/http/routes/retention-ops.js +2 -9
  181. package/dist/http/routes/rules.js +5 -18
  182. package/dist/http/routes/runs.d.ts +5 -0
  183. package/dist/http/routes/runs.js +33 -106
  184. package/dist/http/routes/session-sync.js +15 -28
  185. package/dist/http/routes/sessions.js +30 -40
  186. package/dist/http/routes/side-query.js +6 -4
  187. package/dist/http/routes/tasks.js +50 -14
  188. package/dist/http/routes/workflows.d.ts +351 -0
  189. package/dist/http/routes/workflows.js +153 -119
  190. package/dist/http/send.d.ts +24 -3
  191. package/dist/http/send.js +14 -2
  192. package/dist/http/server.d.ts +17 -18
  193. package/dist/http/server.js +15 -13
  194. package/dist/http/stored-body-replay.js +17 -4
  195. package/dist/http/submit-ctx.js +4 -3
  196. package/dist/http/submit-replay.d.ts +4 -3
  197. package/dist/http/submit-replay.js +5 -4
  198. package/dist/http/wire-types.d.ts +10 -7
  199. package/dist/index.d.ts +1 -1
  200. package/dist/index.js +0 -1
  201. package/dist/lane-home-probe.d.ts +78 -9
  202. package/dist/lane-home-probe.js +78 -12
  203. package/dist/leader/endpoint.d.ts +4 -1
  204. package/dist/leader/endpoint.js +11 -10
  205. package/dist/limit-refusal.d.ts +17 -5
  206. package/dist/limit-refusal.js +4 -1
  207. package/dist/mcp-assertion-header.d.ts +31 -10
  208. package/dist/mcp-assertion-header.js +10 -3
  209. package/dist/mcp-tool-faces.js +1 -3
  210. package/dist/memory-project-layer.d.ts +37 -0
  211. package/dist/memory-project-layer.js +46 -0
  212. package/dist/memory-scope.d.ts +23 -16
  213. package/dist/memory-scope.js +24 -8
  214. package/dist/memory-sync-client.d.ts +27 -13
  215. package/dist/memory-sync-client.js +107 -18
  216. package/dist/model-entry-refusal.d.ts +9 -1
  217. package/dist/model-entry-refusal.js +6 -1
  218. package/dist/model-plane-face.d.ts +51 -0
  219. package/dist/model-plane-face.js +24 -0
  220. package/dist/model-provider.d.ts +5 -0
  221. package/dist/model-provider.js +4 -2
  222. package/dist/model-route-endpoint.d.ts +8 -11
  223. package/dist/model-route-endpoint.js +4 -7
  224. package/dist/model-route-env.d.ts +22 -0
  225. package/dist/model-route-env.js +2 -0
  226. package/dist/model-route-gap.d.ts +60 -30
  227. package/dist/model-route-gap.js +21 -15
  228. package/dist/observability/corrupt-read-seat.d.ts +7 -0
  229. package/dist/observability/corrupt-read-seat.js +9 -0
  230. package/dist/observability/err-text.d.ts +6 -0
  231. package/dist/observability/err-text.js +18 -4
  232. package/dist/observability/fail-open.d.ts +35 -9
  233. package/dist/observability/fail-open.js +35 -9
  234. package/dist/observability/metrics.js +1 -0
  235. package/dist/observability/prompt-manifest.d.ts +3 -17
  236. package/dist/observability/prompt-manifest.js +45 -42
  237. package/dist/observability/tool-trace.js +9 -9
  238. package/dist/operator-ask.d.ts +10 -0
  239. package/dist/operator-ask.js +3 -0
  240. package/dist/orchestration/workflow-agent-session-index.d.ts +12 -12
  241. package/dist/orchestration/workflow-agent-session-index.js +9 -13
  242. package/dist/orchestration/workflow-completion-inbox.d.ts +42 -15
  243. package/dist/orchestration/workflow-completion-inbox.js +13 -0
  244. package/dist/orchestration/workflow-notify-journal.d.ts +13 -32
  245. package/dist/orchestration/workflow-notify-journal.js +27 -34
  246. package/dist/orchestration/workflow-park-decision.d.ts +89 -0
  247. package/dist/orchestration/workflow-park-decision.js +118 -0
  248. package/dist/org-delivery-gate.d.ts +81 -0
  249. package/dist/org-delivery-gate.js +59 -0
  250. package/dist/panel-tool-names.d.ts +23 -0
  251. package/dist/panel-tool-names.js +8 -0
  252. package/dist/parked-decide.d.ts +39 -6
  253. package/dist/parked-decide.js +98 -51
  254. package/dist/parked-revive-surface.d.ts +9 -2
  255. package/dist/parked-revive-surface.js +11 -8
  256. package/dist/permission-rule-list.d.ts +1 -1
  257. package/dist/permission-rule-list.js +2 -2
  258. package/dist/plugins/approval-ask-store-file.d.ts +16 -1
  259. package/dist/plugins/approval-ask-store-file.js +21 -0
  260. package/dist/plugins/approval-ask-store-memory.d.ts +18 -1
  261. package/dist/plugins/approval-ask-store-memory.js +34 -2
  262. package/dist/plugins/approval-ask-store-sql.d.ts +25 -0
  263. package/dist/plugins/approval-ask-store-sql.js +37 -1
  264. package/dist/plugins/blob-backend.d.ts +38 -25
  265. package/dist/plugins/blob-backend.js +29 -19
  266. package/dist/plugins/breaker-state-sql.d.ts +2 -2
  267. package/dist/plugins/breaker-state-sql.js +4 -4
  268. package/dist/plugins/caching-session-store.d.ts +3 -0
  269. package/dist/plugins/caching-session-store.js +3 -0
  270. package/dist/plugins/checkpoint-store-sql.d.ts +14 -0
  271. package/dist/plugins/checkpoint-store-sql.js +20 -2
  272. package/dist/plugins/file-history-store-sql.d.ts +5 -2
  273. package/dist/plugins/file-history-store-sql.js +3 -1
  274. package/dist/plugins/file-run-store.d.ts +5 -13
  275. package/dist/plugins/file-run-store.js +9 -0
  276. package/dist/plugins/fork-routing-session-store.d.ts +9 -1
  277. package/dist/plugins/fork-routing-session-store.js +12 -1
  278. package/dist/plugins/host-platform.d.ts +2 -0
  279. package/dist/plugins/host-platform.js +2 -1
  280. package/dist/plugins/local-checkpoint-store.d.ts +2 -0
  281. package/dist/plugins/local-checkpoint-store.js +6 -1
  282. package/dist/plugins/local-session-store.d.ts +1 -1
  283. package/dist/plugins/local-session-window.d.ts +39 -0
  284. package/dist/plugins/local-session-window.js +54 -0
  285. package/dist/plugins/mailbox-recipient-lifecycle.d.ts +3 -2
  286. package/dist/plugins/mailbox-store-sql.d.ts +30 -48
  287. package/dist/plugins/mailbox-store-sql.js +77 -52
  288. package/dist/plugins/memory-embedder-fingerprint.js +2 -1
  289. package/dist/plugins/memory-run-store.d.ts +5 -13
  290. package/dist/plugins/memory-run-store.js +8 -0
  291. package/dist/plugins/one-time-migrations.d.ts +110 -5
  292. package/dist/plugins/one-time-migrations.js +320 -8
  293. package/dist/plugins/org-delivery-quota-store.d.ts +123 -0
  294. package/dist/plugins/org-delivery-quota-store.js +228 -0
  295. package/dist/plugins/pg-pool.js +39 -7
  296. package/dist/plugins/pg-session-storage.d.ts +17 -4
  297. package/dist/plugins/pg-session-storage.js +29 -6
  298. package/dist/plugins/remote-env-adb.d.ts +6 -12
  299. package/dist/plugins/remote-env-adb.js +12 -2
  300. package/dist/plugins/remote-env-k8s.d.ts +8 -12
  301. package/dist/plugins/remote-env-k8s.js +23 -5
  302. package/dist/plugins/remote-env-local-docker.d.ts +10 -15
  303. package/dist/plugins/remote-env-local-docker.js +12 -9
  304. package/dist/plugins/remote-env-ssh.d.ts +10 -16
  305. package/dist/plugins/remote-env-ssh.js +17 -11
  306. package/dist/plugins/run-store-sql.d.ts +16 -14
  307. package/dist/plugins/run-store-sql.js +22 -2
  308. package/dist/plugins/schema-indexes.js +2 -0
  309. package/dist/plugins/send-user-file.js +2 -2
  310. package/dist/plugins/session-epoch-anchor.d.ts +32 -6
  311. package/dist/plugins/session-epoch-anchor.js +2 -3
  312. package/dist/plugins/session-store.d.ts +18 -8
  313. package/dist/plugins/session-store.js +15 -4
  314. package/dist/plugins/sql-driver.d.ts +74 -11
  315. package/dist/plugins/sql-driver.js +210 -12
  316. package/dist/plugins/sql-errors.d.ts +23 -0
  317. package/dist/plugins/sql-errors.js +21 -0
  318. package/dist/plugins/store-backend.d.ts +36 -1
  319. package/dist/plugins/store-backend.js +40 -14
  320. package/dist/plugins/store-contracts.d.ts +42 -0
  321. package/dist/plugins/store-contracts.js +1 -0
  322. package/dist/plugins/tidb-pool.d.ts +6 -1
  323. package/dist/plugins/tidb-pool.js +44 -10
  324. package/dist/plugins/tidb-session-storage.d.ts +3 -2
  325. package/dist/plugins/tidb-session-storage.js +2 -2
  326. package/dist/plugins/tidb-session-store.d.ts +24 -4
  327. package/dist/plugins/tidb-session-store.js +28 -4
  328. package/dist/plugins/workflow-journal-store-sql.d.ts +73 -19
  329. package/dist/plugins/workflow-journal-store-sql.js +19 -6
  330. package/dist/plugins/workflow-run-store-sql.d.ts +30 -8
  331. package/dist/plugins/workflow-run-store-sql.js +50 -9
  332. package/dist/posture-source.d.ts +18 -10
  333. package/dist/posture-source.js +4 -1
  334. package/dist/project-identity.d.ts +45 -6
  335. package/dist/project-identity.js +162 -8
  336. package/dist/project-layer-key.d.ts +28 -0
  337. package/dist/project-layer-key.js +29 -0
  338. package/dist/question.js +3 -2
  339. package/dist/read-deny-entry.d.ts +25 -0
  340. package/dist/read-deny-entry.js +12 -0
  341. package/dist/read-face-posture.d.ts +37 -11
  342. package/dist/read-face-posture.js +32 -3
  343. package/dist/request-key-closure.d.ts +7 -0
  344. package/dist/request-key-closure.js +9 -1
  345. package/dist/request-value-closure.d.ts +5 -0
  346. package/dist/request-value-closure.js +11 -3
  347. package/dist/rule-entry-list.d.ts +26 -2
  348. package/dist/rule-entry-list.js +72 -12
  349. package/dist/rules-consent.js +3 -4
  350. package/dist/run-acceptance.d.ts +19 -3
  351. package/dist/run-acceptance.js +13 -1
  352. package/dist/run-local.js +36 -6
  353. package/dist/runs.d.ts +10 -9
  354. package/dist/runs.js +21 -3
  355. package/dist/runtime-caps-resolver.d.ts +16 -6
  356. package/dist/runtime-caps-resolver.js +18 -5
  357. package/dist/sandbox-pkg-source.d.ts +19 -1
  358. package/dist/sandbox-pkg-source.js +29 -7
  359. package/dist/security.d.ts +87 -9
  360. package/dist/security.js +52 -9
  361. package/dist/session-error-wire.d.ts +19 -2
  362. package/dist/session-error-wire.js +5 -1
  363. package/dist/session-titled-frame.d.ts +33 -0
  364. package/dist/session-titled-frame.js +4 -0
  365. package/dist/session-titler.d.ts +9 -1
  366. package/dist/session-titler.js +15 -4
  367. package/dist/spec-fields.js +21 -2
  368. package/dist/sql-engine-posture.d.ts +3 -1
  369. package/dist/task-cwd.d.ts +1 -1
  370. package/dist/task-mcp.d.ts +3 -3
  371. package/dist/task-mcp.js +7 -66
  372. package/dist/task-settings.d.ts +89 -88
  373. package/dist/task-settings.js +71 -63
  374. package/dist/text-bidi.d.ts +0 -4
  375. package/dist/text-bidi.js +1 -3
  376. package/dist/tighten-only-seats.d.ts +150 -0
  377. package/dist/tighten-only-seats.js +196 -0
  378. package/dist/tool-approval.d.ts +303 -87
  379. package/dist/tool-approval.js +623 -239
  380. package/dist/trace/core-keyset-guard.d.ts +104 -22
  381. package/dist/trace/exit-seat.d.ts +28 -0
  382. package/dist/trace/exit-seat.js +76 -0
  383. package/dist/trace/ledger-events.d.ts +3 -2
  384. package/dist/trace/project.d.ts +80 -12
  385. package/dist/trace/project.js +41 -28
  386. package/dist/trace/projection-table.d.ts +100 -0
  387. package/dist/trace/projection-table.js +130 -0
  388. package/dist/trace/redact.d.ts +54 -0
  389. package/dist/trace/redact.js +778 -152
  390. package/dist/trace/self-minted-wire.d.ts +345 -1
  391. package/dist/trace/self-minted-wire.js +117 -1
  392. package/dist/trace/wire-projection-faces.d.ts +298 -2
  393. package/dist/trace/wire-projection-faces.js +154 -13
  394. package/dist/url-refusal.js +2 -0
  395. package/dist/web-admin-client/apply-effective.d.ts +12 -0
  396. package/dist/web-admin-client/apply-effective.js +56 -5
  397. package/dist/web-admin-client/facade.d.ts +1 -1
  398. package/dist/web-admin-client/facade.js +1 -1
  399. package/dist/web-admin-client/read-face.js +5 -9
  400. package/dist/web-admin-client/skills-mcp.js +4 -5
  401. package/docs/DEVICE-EXECUTOR-PROTOCOL.md +57 -9
  402. package/package.json +7 -3
@@ -1,20 +1,23 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { composeHostSubprocessEnv, hostSubprocessEnv } from "../server-secret-env.js";
3
3
  import vm from "node:vm";
4
- import { hostShell, resolveHostShell, adoptHostForegroundChild } from "../plugins/host-platform.js";
4
+ import { hostShell, resolveHostShell, adoptHostForegroundChild, killTreeSoft, killTreeHard } from "../plugins/host-platform.js";
5
5
  import { classifyHooksConfig, DEFAULT_HOOK_TIMEOUT_SECONDS, HOOK_ENTRY_TYPES, HOOK_EVENTS, HOOK_EVENT_OWNER, HOOK_EVENT_MATCHER_FIELD, hookMatcherForm, hookMatcherSelects, interpolateHookEnvVars, } from "@sema-agent/settings-schema/hooks";
6
6
  import { formatConfigPath, outgoingPath } from "@sema-agent/settings-schema";
7
7
  import { ENGINE_OWNED_HOOK_EVENTS } from "@sema-agent/settings-schema/hooks";
8
8
  import { describeToolCatalog } from "@sema-agent/core";
9
9
  import { hangClassic } from "./classic-stations.js";
10
- import { boundedNoticeText } from "../fleet/fleet-bus.js";
10
+ import { MAX_HOOK_NOTICE_TEXT_CHARS, boundedNoticeText } from "../fleet/fleet-bus.js";
11
11
  import { recordFailOpen } from "../observability/fail-open.js";
12
12
  import { LIMIT_REASONS } from "../limit-refusal.js";
13
- import { echoCallerText } from "../caller-text-echo.js";
13
+ import { clipDataText, echoCallerText } from "../caller-text-echo.js";
14
14
  import { redactSecrets, buildDisplaySafeUrl } from "../trace/redact.js";
15
+ import { canonicalJson } from "../canonical-json.js";
15
16
  import { ccPromptSystemFor, wrapCondition, parseCcVerdict, CC_EVALUATOR_MAX_OUTPUT_TOKENS } from "./cc-stop-prompt.js";
16
17
  import { ccAgentHookSystemFor } from "./cc-agent-hook-prompt.js";
17
18
  import { renderBranchTranscript } from "./branch-transcript.js";
19
+ import { HOOK_PHASES, HOOK_SOURCE_VOCABULARY_SENTENCE, hookPhaseOf, readHookGroupSource } from "./hook-source.js";
20
+ import { commandLineChars, composeHookEnvLayer, hookPluginChars, planCommandSpawn, readHookArgs, readHookPlugin } from "./command-spawn.js";
18
21
  export const MAX_HOOK_ENTRIES_PER_EVENT = 32;
19
22
  export const MAX_HOOK_COMMAND_CHARS = 8_192;
20
23
  export const MAX_HOOK_TIMEOUT_SECONDS = 600;
@@ -31,11 +34,16 @@ const HOOK_ENV_ALLOWLIST = [
31
34
  "PATH", "HOME", "SHELL", "USER", "LOGNAME", "TERM", "TZ",
32
35
  "LANG", "LC_ALL", "LC_CTYPE", "LC_MESSAGES", "TMPDIR",
33
36
  ];
34
- function hookEnv(ctx, spawning) {
35
- const base = spawning
37
+ function hookBaseEnv(spawning) {
38
+ return spawning
36
39
  ? hostSubprocessEnv({ site: "server.hooks.spawn-env", policy: [...HOOK_ENV_ALLOWLIST], allow: [] })
37
40
  : composeHostSubprocessEnv({ policy: [...HOOK_ENV_ALLOWLIST], allow: [] });
38
- return { ...base, CLAUDE_PROJECT_DIR: ctx.cwd, ...ctx.shellEnv };
41
+ }
42
+ function hookEnvOverBase(layer, spawning) {
43
+ return { ...hookBaseEnv(spawning), ...layer };
44
+ }
45
+ function hookEnv(ctx) {
46
+ return hookEnvOverBase(composeHookEnvLayer(ctx.shellEnv, ctx.cwd, undefined), false);
39
47
  }
40
48
  export const SETTINGS_HOOK_STATIONS = {
41
49
  PreToolUse: ["classic.PreToolUse"],
@@ -60,6 +68,7 @@ export const DEFAULT_SETTINGS_HOOKS_BUDGET_MS = MAX_HOOK_TIMEOUT_SECONDS * 1000;
60
68
  export function settingsHooksBudgetMs(config) {
61
69
  return config.hooksTimeoutMs ?? DEFAULT_SETTINGS_HOOKS_BUDGET_MS;
62
70
  }
71
+ export const HOOK_KILL_GRACE_MS = 1_000;
63
72
  const BRAIN_TO_TURN_ERROR = {
64
73
  auth: "authentication_failed",
65
74
  rate_limit: "rate_limit",
@@ -92,6 +101,10 @@ function isRunnableHookEntry(entry) {
92
101
  export const MAX_MATCHER_EVAL_CHARS = MAX_HOOK_COMMAND_CHARS;
93
102
  export const ADMISSION_MATCHER_BUDGET = { budgetMs: 1_000, totalMs: 5_000 };
94
103
  export const MATCHER_ECHO_CHARS = 200;
104
+ export const IF_ECHO_CHARS = 200;
105
+ const LOG_PREVIEW_ECHO_CHARS = 200;
106
+ const LOG_ERROR_ECHO_CHARS = 300;
107
+ const LOG_MESSAGE_ECHO_CHARS = 500;
95
108
  const MATCHER_UNJUDGED_TAG = "server.hooks.matcher-unjudged";
96
109
  function matcherReadsWireToolName(event) {
97
110
  return HOOK_EVENT_OWNER[event] === "engine" && HOOK_EVENT_MATCHER_FIELD[event] === "tool_name";
@@ -144,6 +157,10 @@ const MATCHER_EVALUATION = {
144
157
  regex: { regex: "budgeted", literal: "not_applicable" },
145
158
  invalid: { regex: "unreadable", literal: "not_applicable" },
146
159
  };
160
+ const RUNTIME_EVAL_DEPTH = "regex";
161
+ function matcherSelectsBy(evaluation, matcher, name, budgetMs) {
162
+ return evaluation === "inline" ? hookMatcherSelects(matcher, name) : matcherSelectsWithin(matcher, name, budgetMs);
163
+ }
147
164
  function judgeMatcher(matcher, depth, ledger, judgesAlias) {
148
165
  if (matcher.length > MAX_MATCHER_EVAL_CHARS)
149
166
  return { kind: "unjudged", reason: "oversize" };
@@ -165,9 +182,9 @@ function judgeMatcher(matcher, depth, ledger, judgesAlias) {
165
182
  }
166
183
  const selects = (name) => {
167
184
  if (evaluate === "inline")
168
- return hookMatcherSelects(matcher, name);
185
+ return matcherSelectsBy(evaluate, matcher, name, ledger.budgetMs);
169
186
  const startedAt = ledger.now();
170
- const answer = matcherSelectsWithin(matcher, name, ledger.budgetMs);
187
+ const answer = matcherSelectsBy(evaluate, matcher, name, ledger.budgetMs);
171
188
  ledger.spentMs += ledger.now() - startedAt;
172
189
  if (answer === "over_budget" || answer === "invalid")
173
190
  return answer;
@@ -205,6 +222,13 @@ const HOOKS_CAPS = {
205
222
  headers: { measure: "entries", cap: MAX_HOOK_HTTP_HEADERS, sentence: (event, count) => `settings.hooks.${event}: ${count} headers exceeds cap ${MAX_HOOK_HTTP_HEADERS}` },
206
223
  header_chars: { measure: "characters", cap: MAX_HOOK_COMMAND_CHARS, sentence: (event) => `settings.hooks.${event}: headers exceed ${MAX_HOOK_COMMAND_CHARS} chars total` },
207
224
  allowed_env_vars: { measure: "entries", cap: MAX_HOOK_HTTP_HEADERS, sentence: (event) => `settings.hooks.${event}: allowedEnvVars exceeds cap ${MAX_HOOK_HTTP_HEADERS}` },
225
+ plugin: { measure: "characters", cap: MAX_HOOK_COMMAND_CHARS, sentence: (event) => `settings.hooks.${event}: plugin context exceeds ${MAX_HOOK_COMMAND_CHARS} chars total` },
226
+ };
227
+ const HOOKS_WIRE_KEY_SHAPE = {
228
+ source: `source must be one of ${HOOK_SOURCE_VOCABULARY_SENTENCE}`,
229
+ plugin: "plugin must be { name, root, dataDir?, options? } — name a non-empty string, root and dataDir absolute paths with no \"..\" segment and no NUL byte, " +
230
+ "options an object of string values whose keys are identifiers ([A-Za-z_][A-Za-z0-9_]*); nothing else",
231
+ args: "args must be an array of strings without NUL bytes (exec form: command is the executable, args its arguments)",
208
232
  };
209
233
  function hooksCapIssue(limit, event, path, count) {
210
234
  const c = HOOKS_CAPS[limit];
@@ -251,6 +275,8 @@ export function buildHooksRefusal(issue) {
251
275
  }
252
276
  case "unnamed_refusal":
253
277
  return { message: `${hooksCoordinate(issue.path)}: the hooks reader refused this configuration without naming a piece`, field, detail: { reason: issue.reason, path: hooksOutgoingPath(issue.path) } };
278
+ case "wire_key_invalid":
279
+ return { message: `${hooksCoordinate(issue.path)}: ${HOOKS_WIRE_KEY_SHAPE[issue.key]}`, field, detail: { reason: issue.reason, path: hooksOutgoingPath(issue.path), key: issue.key } };
254
280
  default: {
255
281
  const piece = issue;
256
282
  const key = piece.key !== undefined ? hooksOutgoingKey(piece.key) : undefined;
@@ -350,6 +376,7 @@ export function parseHooksConfig(raw) {
350
376
  return hooksRefused({ reason: "hooks-not-object", path: ["hooks"] });
351
377
  }
352
378
  const config = {};
379
+ const rawMember = (node, key) => node !== null && typeof node === "object" && Object.hasOwn(node, key) ? node[key] : undefined;
353
380
  for (const key of Object.keys(loaded)) {
354
381
  if (!isHookEventName(key)) {
355
382
  return hooksRefused({ reason: "unknown-event", path: ["hooks", key] });
@@ -363,17 +390,39 @@ export function parseHooksConfig(raw) {
363
390
  let entries = 0;
364
391
  let firstPastCap;
365
392
  const kept = [];
393
+ const rawGroups = rawMember(raw, event);
366
394
  for (const [groupIndex, group] of groups.entries()) {
395
+ const rawGroup = rawMember(rawGroups, groupIndex);
396
+ const rawSource = rawMember(rawGroup, "source");
397
+ const source = rawSource === undefined ? undefined : readHookGroupSource(rawSource);
398
+ if (rawSource !== undefined && source === undefined)
399
+ return hooksRefused({ reason: "wire_key_invalid", path: ["hooks", event, groupIndex, "source"], key: "source" });
367
400
  const hooks = [];
368
- for (const [entryIndex, entry] of group.hooks.entries()) {
401
+ for (const [entryIndex, contractEntry] of group.hooks.entries()) {
369
402
  entries++;
370
403
  const at = ["hooks", event, groupIndex, "hooks", entryIndex];
371
404
  if (entries === MAX_HOOK_ENTRIES_PER_EVENT + 1)
372
405
  firstPastCap = at;
373
- if (!isRunnableHookEntry(entry))
374
- return hooksRefused({ reason: "unsupported_entry_type", path: [...at, "type"], type: entry.type });
375
- if (entry.type === "command" && entry.command.length > MAX_HOOK_COMMAND_CHARS)
376
- return hooksRefused(hooksCapIssue("command", event, [...at, "command"], entry.command.length));
406
+ if (!isRunnableHookEntry(contractEntry))
407
+ return hooksRefused({ reason: "unsupported_entry_type", path: [...at, "type"], type: contractEntry.type });
408
+ let entry = contractEntry;
409
+ if (entry.type === "command") {
410
+ const rawEntry = rawMember(rawMember(rawGroup, "hooks"), entryIndex);
411
+ const rawArgs = rawMember(rawEntry, "args");
412
+ const rawPlugin = rawMember(rawEntry, "plugin");
413
+ const args = rawArgs === undefined ? undefined : readHookArgs(rawArgs);
414
+ if (rawArgs !== undefined && args === undefined)
415
+ return hooksRefused({ reason: "wire_key_invalid", path: [...at, "args"], key: "args" });
416
+ const plugin = rawPlugin === undefined ? undefined : readHookPlugin(rawPlugin);
417
+ if (rawPlugin !== undefined && plugin === undefined)
418
+ return hooksRefused({ reason: "wire_key_invalid", path: [...at, "plugin"], key: "plugin" });
419
+ entry = { ...entry, ...(args !== undefined ? { args } : {}), ...(plugin !== undefined ? { plugin } : {}) };
420
+ const line = commandLineChars(entry);
421
+ if (line > MAX_HOOK_COMMAND_CHARS)
422
+ return hooksRefused(hooksCapIssue("command", event, [...at, "command"], line));
423
+ if (plugin !== undefined && hookPluginChars(plugin) > MAX_HOOK_COMMAND_CHARS)
424
+ return hooksRefused(hooksCapIssue("plugin", event, [...at, "plugin"], hookPluginChars(plugin)));
425
+ }
377
426
  if (entry.type === "http") {
378
427
  if (entry.url.length > MAX_HOOK_COMMAND_CHARS)
379
428
  return hooksRefused(hooksCapIssue("url", event, [...at, "url"], entry.url.length));
@@ -389,7 +438,7 @@ export function parseHooksConfig(raw) {
389
438
  }
390
439
  hooks.push(entry);
391
440
  }
392
- kept.push(group.matcher !== undefined ? { matcher: group.matcher, hooks } : { hooks });
441
+ kept.push({ ...(group.matcher !== undefined ? { matcher: group.matcher } : {}), ...(source !== undefined ? { source } : {}), hooks });
393
442
  }
394
443
  if (firstPastCap !== undefined)
395
444
  return hooksRefused(hooksCapIssue("event_entries", event, firstPastCap, entries));
@@ -398,7 +447,18 @@ export function parseHooksConfig(raw) {
398
447
  return { config };
399
448
  }
400
449
  export function hookMatcherMatches(matcher, value) {
401
- return hookMatcherSelects(matcher, value);
450
+ const evaluate = MATCHER_EVALUATION[hookMatcherForm(matcher)][RUNTIME_EVAL_DEPTH];
451
+ switch (evaluate) {
452
+ case "inline":
453
+ case "budgeted":
454
+ return matcherSelectsBy(evaluate, matcher, value, ADMISSION_MATCHER_BUDGET.budgetMs);
455
+ case "unreadable":
456
+ return "invalid";
457
+ default: {
458
+ const unreachable = evaluate;
459
+ return unreachable;
460
+ }
461
+ }
402
462
  }
403
463
  export function hookIfMatches(cond, payload) {
404
464
  if (cond.length > 500 || (cond.match(/\*/g)?.length ?? 0) > 16)
@@ -411,10 +471,20 @@ export function hookIfMatches(cond, payload) {
411
471
  if (!m)
412
472
  return "unevaluable";
413
473
  const nameOk = hookMatcherMatches(m[1].trim(), toolName);
414
- if (nameOk === "invalid")
415
- return "unevaluable";
416
- if (!nameOk)
417
- return "not_matched";
474
+ switch (nameOk) {
475
+ case "invalid":
476
+ return "unevaluable";
477
+ case "over_budget":
478
+ return "over_budget";
479
+ case false:
480
+ return "not_matched";
481
+ case true:
482
+ break;
483
+ default: {
484
+ const unreachable = nameOk;
485
+ return unreachable;
486
+ }
487
+ }
418
488
  const spec = m[2];
419
489
  if (spec === undefined || spec === "*" || spec === "")
420
490
  return true;
@@ -433,8 +503,11 @@ export function hookIfMatches(cond, payload) {
433
503
  const MAX_LLM_ENTRIES_PER_EVENT = 4;
434
504
  const DEFAULT_PROMPT_HOOK_TIMEOUT_SECONDS = 30;
435
505
  const DEFAULT_AGENT_HOOK_TIMEOUT_SECONDS = 120;
436
- function clip(s, n) {
437
- return s.length > n ? `${s.slice(0, n)}…(${s.length})` : s;
506
+ function logHookCommandFailed(ctx, event, run) {
507
+ ctx.logger.warn("hook_command_failed", { event, code: run.code, stderr: echoCallerText(run.stderr, { maxChars: LOG_ERROR_ECHO_CHARS }) });
508
+ }
509
+ function logHookSystemMessage(ctx, event, message) {
510
+ ctx.logger.info("hook_system_message", { event, message: echoCallerText(message, { maxChars: LOG_MESSAGE_ECHO_CHARS }) });
438
511
  }
439
512
  function hookDisplayName(entry) {
440
513
  if (typeof entry.statusMessage === "string" && entry.statusMessage.length > 0)
@@ -463,7 +536,7 @@ function emitHookNotice(ctx, n) {
463
536
  }
464
537
  catch (e) {
465
538
  recordFailOpen("server.hooks.notice-sink-threw", `kind=${n.kind} event=${n.event}`);
466
- ctx.logger.warn("hook_notice_sink_threw", { kind: n.kind, event: n.event, error: clip(String(e), 200) });
539
+ ctx.logger.warn("hook_notice_sink_threw", { kind: n.kind, event: n.event, error: echoCallerText(String(e), { maxChars: LOG_PREVIEW_ECHO_CHARS }) });
467
540
  }
468
541
  }
469
542
  function noteHookFailure(ctx, args) {
@@ -482,6 +555,147 @@ function noteHookFailure(ctx, args) {
482
555
  ...(args.detail !== undefined ? { detail: boundedNoticeText(args.detail) } : {}),
483
556
  });
484
557
  }
558
+ function noteMatcherOverBudget(ctx, args) {
559
+ const budgetMs = ADMISSION_MATCHER_BUDGET.budgetMs;
560
+ const shown = (text) => echoCallerText(boundedNoticeText(text), { maxChars: MATCHER_ECHO_CHARS });
561
+ const expression = shown(args.expression);
562
+ const value = shown(args.value);
563
+ ctx.logger.warn("hook_matcher_over_budget", { event: args.event, scope: args.scope, expression, value, budgetMs, selected: true });
564
+ const first = args.entries[0];
565
+ if (first === undefined)
566
+ return;
567
+ const what = args.scope === "if" ? "if condition" : "matcher";
568
+ const count = args.entries.length;
569
+ noteHookFailure(ctx, {
570
+ event: args.event,
571
+ entry: first,
572
+ reason: "matcher_over_budget",
573
+ ...(args.toolName !== undefined ? { toolName: args.toolName } : {}),
574
+ detail: `${what} \`${expression}\` did not finish within ${budgetMs} ms on \`${value}\`; treated as selected (${count} ${count === 1 ? "entry" : "entries"})`,
575
+ });
576
+ }
577
+ function matcherGroupSelected(ctx, event, group, value, toolName) {
578
+ const answer = hookMatcherMatches(group.matcher, value);
579
+ switch (answer) {
580
+ case true:
581
+ return true;
582
+ case false:
583
+ return false;
584
+ case "invalid":
585
+ ctx.logger.warn("hook_matcher_invalid", { event, matcher: echoCallerText(group.matcher ?? "", { maxChars: MATCHER_ECHO_CHARS }) });
586
+ return "invalid";
587
+ case "over_budget":
588
+ noteMatcherOverBudget(ctx, { event, scope: "matcher", expression: group.matcher ?? "", value, entries: group.hooks, ...(toolName !== undefined ? { toolName } : {}) });
589
+ return "over_budget";
590
+ default: {
591
+ const unreachable = answer;
592
+ return unreachable;
593
+ }
594
+ }
595
+ }
596
+ export const PERMISSIVE_VERDICT_EVENTS = {
597
+ PreToolUse: true,
598
+ PostToolUse: false,
599
+ PostToolUseFailure: false,
600
+ PostToolBatch: false,
601
+ Notification: false,
602
+ UserPromptSubmit: false,
603
+ UserPromptExpansion: false,
604
+ SessionStart: false,
605
+ SessionEnd: false,
606
+ Stop: false,
607
+ StopFailure: false,
608
+ SubagentStart: false,
609
+ SubagentStop: false,
610
+ PreCompact: false,
611
+ PostCompact: false,
612
+ PermissionRequest: false,
613
+ PermissionDenied: false,
614
+ Setup: false,
615
+ TeammateIdle: false,
616
+ TaskCreated: false,
617
+ TaskCompleted: false,
618
+ Elicitation: false,
619
+ ElicitationResult: false,
620
+ ConfigChange: false,
621
+ WorktreeCreate: false,
622
+ WorktreeRemove: false,
623
+ InstructionsLoaded: false,
624
+ CwdChanged: false,
625
+ FileChanged: false,
626
+ MessageDisplay: false,
627
+ DirectoryAdded: false,
628
+ PreModelSwitch: false,
629
+ PostModelSwitch: false,
630
+ };
631
+ const DISTRUST_DETAIL = {
632
+ input_truncated: `the hook did not receive the whole payload (tool_input over ${MAX_TOOL_INPUT_CHARS} characters was replaced by a truncation marker, or a prompt / agent entry's payload text was clipped at that size); its allow / updatedInput are not honored`,
633
+ matcher_over_budget: "the matcher (or the if condition's tool-name part) did not finish within its budget; the entry ran as selected, so its allow / updatedInput are not honored",
634
+ };
635
+ function noteVerdictUntrusted(ctx, args) {
636
+ const notice = {
637
+ kind: "hook_verdict_untrusted",
638
+ event: args.event,
639
+ reason: args.reason,
640
+ lane: hookPhaseOf(args.source) === "unmanaged" ? "unmanaged" : "managed",
641
+ hookName: boundedNoticeText(hookDisplayName(args.entry)),
642
+ entryType: args.entry.type,
643
+ ...(args.toolName !== undefined ? { toolName: args.toolName } : {}),
644
+ ...(args.source !== undefined ? { source: boundedNoticeText(args.source) } : {}),
645
+ detail: boundedNoticeText(DISTRUST_DETAIL[args.reason]),
646
+ };
647
+ const logEcho = (text) => echoCallerText(text, { maxChars: MAX_HOOK_NOTICE_TEXT_CHARS });
648
+ ctx.logger.warn("hook_verdict_not_honored", {
649
+ event: notice.event,
650
+ reason: notice.reason,
651
+ lane: notice.lane,
652
+ hookName: logEcho(notice.hookName),
653
+ entryType: notice.entryType,
654
+ ...(notice.toolName !== undefined ? { toolName: notice.toolName } : {}),
655
+ ...(notice.source !== undefined ? { source: logEcho(notice.source) } : {}),
656
+ detail: notice.detail,
657
+ });
658
+ if (!ctx.onHookNotice)
659
+ return;
660
+ emitHookNotice(ctx, notice);
661
+ }
662
+ function createUnrunLedger(ctx, event, toolName) {
663
+ let managedWithoutVerdict = false;
664
+ return {
665
+ untrusted(source, entry, reason) {
666
+ if (hookPhaseOf(source) === "unmanaged")
667
+ return;
668
+ managedWithoutVerdict = true;
669
+ noteVerdictUntrusted(ctx, { event, reason, entry, source, toolName });
670
+ },
671
+ unrun(source, entry, reason, detail) {
672
+ if (source === undefined || hookPhaseOf(source) === "unmanaged")
673
+ return;
674
+ managedWithoutVerdict = true;
675
+ ctx.logger.warn("hook_managed_unrun", { event, reason, source: echoCallerText(source), type: entry.type, ...(toolName !== undefined ? { tool: toolName } : {}) });
676
+ if (!ctx.onHookNotice)
677
+ return;
678
+ emitHookNotice(ctx, {
679
+ kind: "hook_managed_unrun",
680
+ event,
681
+ reason,
682
+ source: boundedNoticeText(source),
683
+ hookName: boundedNoticeText(hookDisplayName(entry)),
684
+ entryType: entry.type,
685
+ ...(toolName !== undefined ? { toolName } : {}),
686
+ ...(detail !== undefined ? { detail: boundedNoticeText(detail) } : {}),
687
+ });
688
+ },
689
+ get managedWithoutVerdict() {
690
+ return managedWithoutVerdict;
691
+ },
692
+ };
693
+ }
694
+ function noteExitNonzero(ctx, ledger, event, ran, toolName, detail) {
695
+ logHookCommandFailed(ctx, event, ran.run);
696
+ noteHookFailure(ctx, { event, entry: ran.entry, reason: "exit_nonzero", exitCode: ran.run.code, stderr: ran.run.stderr, ...(toolName !== undefined ? { toolName } : {}), ...(detail !== undefined ? { detail } : {}) });
697
+ ledger.unrun(ran.source, ran.entry, "exit_nonzero");
698
+ }
485
699
  export function buildHookContext(parts) {
486
700
  const present = parts.filter((p) => p.length > 0);
487
701
  if (present.length === 0)
@@ -521,24 +735,32 @@ function buildHookContextField(parts) {
521
735
  const text = buildHookContext(parts);
522
736
  return text === undefined ? {} : { additionalContext: text };
523
737
  }
524
- async function runCommandHook(entry, payload, ctx) {
525
- try {
526
- await resolveHostShell();
527
- }
528
- catch (e) {
529
- return { code: null, stdout: "", stderr: "", timedOut: false, spawnError: String(e) };
738
+ function spawnPlannedCommand(plan, ctx, stdio) {
739
+ const env = hookEnvOverBase(plan.env, true);
740
+ if (plan.kind === "exec")
741
+ return spawn(plan.file, [...plan.args], { cwd: ctx.cwd, env, stdio, detached: true });
742
+ const sh = hostShell();
743
+ return spawn(sh.shell, [...sh.args, plan.command], { cwd: ctx.cwd, env, stdio, detached: true });
744
+ }
745
+ async function runCommandHook(entry, payload, ctx, halt) {
746
+ const plan = await planCommandSpawn(entry, ctx.cwd, ctx.shellEnv);
747
+ if (plan.kind === "refused")
748
+ return { code: null, stdout: "", stderr: "", timedOut: false, spawnError: plan.error };
749
+ if (plan.kind === "shell") {
750
+ try {
751
+ await resolveHostShell();
752
+ }
753
+ catch (e) {
754
+ return { code: null, stdout: "", stderr: "", timedOut: false, spawnError: String(e) };
755
+ }
530
756
  }
757
+ if (halt.aborted)
758
+ return { code: null, stdout: "", stderr: "", timedOut: false, aborted: {} };
531
759
  return new Promise((resolve) => {
532
760
  const timeoutMs = Math.min(entry.timeout ?? DEFAULT_HOOK_TIMEOUT_SECONDS, MAX_HOOK_TIMEOUT_SECONDS) * 1000;
533
761
  let child;
534
762
  try {
535
- const sh = hostShell();
536
- child = spawn(sh.shell, [...sh.args, entry.command], {
537
- cwd: ctx.cwd,
538
- env: hookEnv(ctx, true),
539
- stdio: ["pipe", "pipe", "pipe"],
540
- detached: true,
541
- });
763
+ child = spawnPlannedCommand(plan, ctx, ["pipe", "pipe", "pipe"]);
542
764
  }
543
765
  catch (e) {
544
766
  resolve({ code: null, stdout: "", stderr: "", timedOut: false, spawnError: String(e) });
@@ -549,16 +771,20 @@ async function runCommandHook(entry, payload, ctx) {
549
771
  let stderr = "";
550
772
  let timedOut = false;
551
773
  let settled = false;
774
+ let sent;
552
775
  const settle = (code) => {
553
776
  if (settled)
554
777
  return;
555
778
  settled = true;
556
779
  clearTimeout(timer);
780
+ halt.removeEventListener("abort", onHalt);
557
781
  ticket?.release();
558
- resolve({ code, stdout, stderr, timedOut });
782
+ resolve({ code, stdout, stderr, timedOut, ...(sent !== undefined ? { aborted: { signal: sent } } : {}) });
559
783
  };
560
784
  const timer = setTimeout(() => {
561
785
  timedOut = true;
786
+ if (sent !== undefined)
787
+ sent = "SIGKILL";
562
788
  try {
563
789
  if (child.pid)
564
790
  process.kill(-child.pid, "SIGKILL");
@@ -570,6 +796,31 @@ async function runCommandHook(entry, payload, ctx) {
570
796
  }
571
797
  settle(null);
572
798
  }, timeoutMs);
799
+ const signalGroup = (sig) => {
800
+ try {
801
+ if (child.pid)
802
+ (sig === "SIGTERM" ? killTreeSoft : killTreeHard)(child.pid);
803
+ else
804
+ child.kill(sig);
805
+ }
806
+ catch {
807
+ child.kill(sig);
808
+ }
809
+ };
810
+ const onHalt = () => {
811
+ if (settled)
812
+ return;
813
+ sent = "SIGTERM";
814
+ signalGroup("SIGTERM");
815
+ const escalate = setTimeout(() => {
816
+ if (child.exitCode !== null || child.signalCode !== null)
817
+ return;
818
+ sent = "SIGKILL";
819
+ signalGroup("SIGKILL");
820
+ }, HOOK_KILL_GRACE_MS);
821
+ escalate.unref();
822
+ };
823
+ halt.addEventListener("abort", onHalt, { once: true });
573
824
  child.stdout?.on("data", (d) => {
574
825
  if (stdout.length < MAX_HOOK_OUTPUT_BYTES)
575
826
  stdout += d.toString("utf8").slice(0, MAX_HOOK_OUTPUT_BYTES - stdout.length);
@@ -580,6 +831,7 @@ async function runCommandHook(entry, payload, ctx) {
580
831
  });
581
832
  child.on("error", (e) => {
582
833
  clearTimeout(timer);
834
+ halt.removeEventListener("abort", onHalt);
583
835
  if (!settled) {
584
836
  settled = true;
585
837
  resolve({ code: null, stdout, stderr, timedOut, spawnError: String(e) });
@@ -590,18 +842,18 @@ async function runCommandHook(entry, payload, ctx) {
590
842
  child.stdin?.end(`${JSON.stringify(boundedToolInputPayload(payload))}\n`);
591
843
  });
592
844
  }
593
- async function fireAsyncCommandHook(entry, payload, ctx, event) {
845
+ async function fireAsyncCommandHook(entry, payload, ctx, event, halt) {
594
846
  try {
595
847
  const rewake = entry.asyncRewake === true;
596
848
  const timeoutMs = Math.min(entry.timeout ?? DEFAULT_HOOK_TIMEOUT_SECONDS, MAX_HOOK_TIMEOUT_SECONDS) * 1000;
597
- await resolveHostShell();
598
- const sh = hostShell();
599
- const child = spawn(sh.shell, [...sh.args, entry.command], {
600
- cwd: ctx.cwd,
601
- env: hookEnv(ctx, true),
602
- stdio: ["pipe", "ignore", rewake ? "pipe" : "ignore"],
603
- detached: true,
604
- });
849
+ const plan = await planCommandSpawn(entry, ctx.cwd, ctx.shellEnv);
850
+ if (plan.kind === "refused")
851
+ throw new Error(plan.error);
852
+ if (plan.kind === "shell")
853
+ await resolveHostShell();
854
+ if (halt.aborted)
855
+ return;
856
+ const child = spawnPlannedCommand(plan, ctx, ["pipe", "ignore", rewake ? "pipe" : "ignore"]);
605
857
  const ticket = adoptHostForegroundChild(child);
606
858
  let killedByTimeout = false;
607
859
  const timer = setTimeout(() => {
@@ -627,7 +879,7 @@ async function fireAsyncCommandHook(entry, payload, ctx, event) {
627
879
  }
628
880
  child.on("error", (e) => {
629
881
  clearTimeout(timer);
630
- ctx.logger.warn("hook_async_failed", { event, reason: "spawn_failed", error: clip(String(e), 300) });
882
+ ctx.logger.warn("hook_async_failed", { event, reason: "spawn_failed", error: echoCallerText(String(e), { maxChars: LOG_ERROR_ECHO_CHARS }) });
631
883
  noteHookFailure(ctx, { event, entry, reason: "spawn_failed", stderr: String(e), toolName: toolNameOf(payload) });
632
884
  });
633
885
  child.on("close", (code) => {
@@ -650,14 +902,14 @@ async function fireAsyncCommandHook(entry, payload, ctx, event) {
650
902
  ctx.logger.info("hook_async_rewake_delivered", { event, chars: text.length });
651
903
  else
652
904
  ctx.logger.warn("hook_async_rewake_dropped", { event, reason: "no_live_stream" });
653
- }, (e) => ctx.logger.warn("hook_async_rewake_dropped", { event, reason: "wake_failed", error: String(e).slice(0, 200) }));
905
+ }, (e) => ctx.logger.warn("hook_async_rewake_dropped", { event, reason: "wake_failed", error: echoCallerText(String(e), { maxChars: LOG_PREVIEW_ECHO_CHARS }) }));
654
906
  });
655
907
  child.stdin?.on("error", () => { });
656
908
  child.stdin?.end(`${JSON.stringify(boundedToolInputPayload(payload))}\n`);
657
909
  child.unref();
658
910
  }
659
911
  catch (e) {
660
- ctx.logger.warn("hook_async_failed", { event, reason: "spawn_failed", error: clip(String(e), 300) });
912
+ ctx.logger.warn("hook_async_failed", { event, reason: "spawn_failed", error: echoCallerText(String(e), { maxChars: LOG_ERROR_ECHO_CHARS }) });
661
913
  noteHookFailure(ctx, { event, entry, reason: "spawn_failed", stderr: String(e), toolName: toolNameOf(payload) });
662
914
  }
663
915
  }
@@ -682,14 +934,19 @@ function noteAsyncOutcome(entry, ctx, event, payload, outcome) {
682
934
  ...(code === 2 ? { detail: "exit 2 on a fire-and-forget async hook: there is no blocking or wake face to honor it (asyncRewake would wake the model)" } : {}),
683
935
  });
684
936
  }
937
+ function llmHookArguments(payload) {
938
+ const bounded = boundedToolInputPayload(payload);
939
+ const json = JSON.stringify(bounded);
940
+ const text = clipDataText(json, MAX_TOOL_INPUT_CHARS);
941
+ return { text, truncated: bounded !== payload || text !== json };
942
+ }
685
943
  async function runLlmHook(entry, payload, ctx, extra) {
686
944
  const call = entry.type === "prompt" ? ctx.hookLlm : ctx.hookAgent;
687
945
  if (!call)
688
946
  return { code: null, stdout: "", stderr: "", timedOut: false, spawnError: `no ${entry.type} hook carrier wired on this deployment` };
689
947
  const defSec = entry.type === "prompt" ? DEFAULT_PROMPT_HOOK_TIMEOUT_SECONDS : DEFAULT_AGENT_HOOK_TIMEOUT_SECONDS;
690
948
  const timeoutMs = Math.min(entry.timeout ?? defSec, MAX_HOOK_TIMEOUT_SECONDS) * 1000;
691
- const args = clip(JSON.stringify(boundedToolInputPayload(payload)), MAX_TOOL_INPUT_CHARS);
692
- const substituted = entry.prompt.split("$ARGUMENTS").join(args);
949
+ const substituted = entry.prompt.split("$ARGUMENTS").join(llmHookArguments(payload).text);
693
950
  const prompt = extra?.transcript !== undefined ? `${extra.transcript}\n\n---\n\n${wrapCondition("Stop", substituted)}` : substituted;
694
951
  let hardTopTimer;
695
952
  const hardTop = new Promise((r) => {
@@ -719,9 +976,9 @@ async function runLlmHook(entry, payload, ctx, extra) {
719
976
  if (!res.ok) {
720
977
  return res.code === "hard_timeout"
721
978
  ? { code: null, stdout: "", stderr: "", timedOut: true, ...(noticed ? { noticed } : {}) }
722
- : { code: null, stdout: "", stderr: "", timedOut: false, spawnError: clip(res.error, 500), ...(noticed ? { noticed } : {}) };
979
+ : { code: null, stdout: "", stderr: "", timedOut: false, spawnError: res.error, ...(noticed ? { noticed } : {}) };
723
980
  }
724
- return { code: 0, stdout: clip(res.text, 1024 * 1024), stderr: "", timedOut: false };
981
+ return { code: 0, stdout: clipDataText(res.text, 1024 * 1024), stderr: "", timedOut: false };
725
982
  }
726
983
  finally {
727
984
  if (hardTopTimer !== undefined)
@@ -731,13 +988,15 @@ async function runLlmHook(entry, payload, ctx, extra) {
731
988
  const MAX_BLANKED_REFERENCES_LOGGED = 16;
732
989
  async function runHttpHook(entry, payload, ctx, event) {
733
990
  const timeoutMs = Math.min(entry.timeout ?? DEFAULT_HOOK_TIMEOUT_SECONDS, MAX_HOOK_TIMEOUT_SECONDS) * 1000;
734
- const interpolated = interpolateHookEnvVars(entry.headers ?? {}, entry.allowedEnvVars ?? [], hookEnv(ctx, false));
991
+ const interpolated = interpolateHookEnvVars(entry.headers ?? {}, entry.allowedEnvVars ?? [], hookEnv(ctx));
735
992
  if (interpolated.blanked.length > 0) {
736
993
  ctx.logger.warn("hook_http_header_env_blanked", {
737
994
  event,
738
995
  url: buildDisplaySafeUrl(entry.url),
739
996
  blankedCount: interpolated.blanked.length,
740
- blanked: interpolated.blanked.slice(0, MAX_BLANKED_REFERENCES_LOGGED).map(({ header, name, reason }) => ({ header, name, reason })),
997
+ blanked: interpolated.blanked
998
+ .slice(0, MAX_BLANKED_REFERENCES_LOGGED)
999
+ .map(({ header, name, reason }) => ({ header: echoCallerText(header, { maxChars: LOG_PREVIEW_ECHO_CHARS }), name: echoCallerText(name, { maxChars: LOG_PREVIEW_ECHO_CHARS }), reason })),
741
1000
  });
742
1001
  }
743
1002
  const headers = { "content-type": "application/json", ...interpolated.headers };
@@ -775,7 +1034,7 @@ async function runHttpHook(entry, payload, ctx, event) {
775
1034
  if (trimmed === "")
776
1035
  return { code: 0, stdout: "{}", stderr: "", timedOut: false };
777
1036
  if (!parseHookStdout(body)) {
778
- ctx.logger.warn("hook_http_non_json_response", { url: buildDisplaySafeUrl(entry.url), preview: clip(trimmed, 200) });
1037
+ ctx.logger.warn("hook_http_non_json_response", { url: buildDisplaySafeUrl(entry.url), preview: echoCallerText(trimmed, { maxChars: LOG_PREVIEW_ECHO_CHARS }) });
779
1038
  noteHookFailure(ctx, {
780
1039
  event: eventOfPayload(payload),
781
1040
  entry,
@@ -784,7 +1043,7 @@ async function runHttpHook(entry, payload, ctx, event) {
784
1043
  stderr: trimmed,
785
1044
  toolName: toolNameOf(payload),
786
1045
  });
787
- return { code: 0, stdout: "", stderr: "", timedOut: false };
1046
+ return { code: 0, stdout: "", stderr: "", timedOut: false, badJson: true };
788
1047
  }
789
1048
  return { code: 0, stdout: body, stderr: "", timedOut: false };
790
1049
  }
@@ -820,14 +1079,16 @@ function boundedToolInputPayload(payload) {
820
1079
  const bounded = boundedInputValue(p.tool_input);
821
1080
  return bounded === p.tool_input ? payload : { ...p, tool_input: bounded };
822
1081
  }
823
- function readHookStdout(run, entry, event, ctx, payload) {
1082
+ function readHookStdout(ran, event, ctx, payload, ledger) {
1083
+ const { run, entry } = ran;
824
1084
  const out = parseHookStdout(run.stdout);
825
1085
  if (out !== undefined)
826
1086
  return out;
827
1087
  const t = run.stdout.trim();
828
1088
  if (t.startsWith("{") || t.startsWith("[")) {
829
- ctx.logger.warn("hook_bad_json_output", { event, type: entry.type, preview: clip(t, 200) });
1089
+ ctx.logger.warn("hook_bad_json_output", { event, type: entry.type, preview: echoCallerText(t, { maxChars: LOG_PREVIEW_ECHO_CHARS }) });
830
1090
  noteHookFailure(ctx, { event, entry, reason: "bad_json", exitCode: run.code, stderr: run.stderr, toolName: toolNameOf(payload) });
1091
+ ledger.unrun(ran.source, entry, "bad_json");
831
1092
  }
832
1093
  return undefined;
833
1094
  }
@@ -843,7 +1104,7 @@ function parseHookStdout(stdout) {
843
1104
  return undefined;
844
1105
  }
845
1106
  }
846
- function runRunnableEntry(entry, payload, ctx, event, llmExtra) {
1107
+ function runRunnableEntry(entry, payload, ctx, event, llmExtra, halt) {
847
1108
  switch (entry.type) {
848
1109
  case "http":
849
1110
  return runHttpHook(entry, payload, ctx, event);
@@ -852,7 +1113,7 @@ function runRunnableEntry(entry, payload, ctx, event, llmExtra) {
852
1113
  case "agent":
853
1114
  return runLlmHook(entry, payload, ctx, { system: ccAgentHookSystemFor(event), event });
854
1115
  case "command":
855
- return runCommandHook(entry, payload, ctx);
1116
+ return runCommandHook(entry, payload, ctx, halt);
856
1117
  default: {
857
1118
  const unreachable = entry;
858
1119
  void unreachable;
@@ -860,79 +1121,162 @@ function runRunnableEntry(entry, payload, ctx, event, llmExtra) {
860
1121
  }
861
1122
  }
862
1123
  }
863
- async function runMatchingEntries(event, groups, matchValue, payload, ctx, onceFired, deadlineAt, keyBase, llmExtra) {
1124
+ function isSyncEntry(entry) {
1125
+ return !(entry.type === "command" && (entry.async === true || entry.asyncRewake === true));
1126
+ }
1127
+ async function runMatchingEntries(event, groups, matchValue, payload, ctx, onceFired, halt, deadlineAt, ledger, llmExtra) {
864
1128
  const results = [];
865
1129
  let llmEntries = 0;
866
- let gi = -1;
867
- for (const group of groups) {
868
- gi++;
869
- const m = hookMatcherMatches(group.matcher, matchValue);
870
- if (m === "invalid") {
871
- ctx.logger.warn("hook_matcher_invalid", { event, matcher: echoCallerText(group.matcher ?? "", { maxChars: MATCHER_ECHO_CHARS }) });
872
- continue;
873
- }
874
- if (!m)
875
- continue;
876
- let ei = -1;
877
- for (const entry of group.hooks) {
878
- ei++;
879
- const onceKey = `${keyBase ?? event}:${gi}:${ei}`;
880
- if (entry.once && onceFired.has(onceKey))
881
- continue;
882
- if (entry.type === "prompt" || entry.type === "agent") {
883
- const carrier = entry.type === "prompt" ? ctx.hookLlm : ctx.hookAgent;
884
- if (!carrier) {
885
- ctx.logger.warn("hook_entries_skipped", { event, type: entry.type, reason: "llm_carrier_unavailable" });
886
- continue;
887
- }
888
- if (llmEntries >= MAX_LLM_ENTRIES_PER_EVENT) {
889
- ctx.logger.warn("hook_entries_skipped", { event, type: entry.type, reason: "llm_entries_per_event_cap", cap: MAX_LLM_ENTRIES_PER_EVENT });
890
- continue;
891
- }
892
- }
893
- if (entry.type === "command" && entry.shell === "powershell") {
894
- ctx.logger.warn("hook_entries_skipped", { event, type: entry.type, reason: "powershell_not_supported" });
895
- continue;
896
- }
897
- if (entry.if !== undefined) {
898
- const iv = hookIfMatches(entry.if, payload);
899
- if (iv !== true) {
900
- ctx.logger.warn("hook_entries_skipped", { event, type: entry.type, reason: iv === "unevaluable" ? "if_unevaluable" : "if_not_matched", if: clip(entry.if, 200) });
901
- continue;
1130
+ const toolName = toolNameOf(payload);
1131
+ const inputTruncated = boundedToolInputPayload(payload) !== payload;
1132
+ const truncatedFor = (entry) => (entry.type === "prompt" || entry.type === "agent" ? llmHookArguments(payload).truncated : inputTruncated);
1133
+ const settle = async (entry, onceKey) => {
1134
+ if (halt.aborted)
1135
+ return { kind: "halted" };
1136
+ if (entry.once && onceFired.has(onceKey))
1137
+ return { kind: "passed" };
1138
+ let ifOverBudget = false;
1139
+ if (entry.if !== undefined) {
1140
+ const iv = hookIfMatches(entry.if, payload);
1141
+ switch (iv) {
1142
+ case true:
1143
+ break;
1144
+ case "over_budget":
1145
+ noteMatcherOverBudget(ctx, { event, scope: "if", expression: entry.if, value: toolName ?? "", entries: [entry], ...(toolName !== undefined ? { toolName } : {}) });
1146
+ ifOverBudget = true;
1147
+ break;
1148
+ case "not_matched":
1149
+ ctx.logger.warn("hook_entries_skipped", { event, type: entry.type, reason: "if_not_matched", if: echoCallerText(entry.if, { maxChars: IF_ECHO_CHARS }) });
1150
+ return { kind: "passed" };
1151
+ case "unevaluable":
1152
+ ctx.logger.warn("hook_entries_skipped", { event, type: entry.type, reason: "if_unevaluable", if: echoCallerText(entry.if, { maxChars: IF_ECHO_CHARS }) });
1153
+ return { kind: "unrun", reason: "if_unevaluable" };
1154
+ default: {
1155
+ const unreachable = iv;
1156
+ return unreachable;
902
1157
  }
903
1158
  }
904
- if (entry.type === "command" && (entry.async || entry.asyncRewake)) {
905
- if (entry.once)
906
- onceFired.add(onceKey);
907
- void fireAsyncCommandHook(entry, payload, ctx, event);
908
- continue;
1159
+ }
1160
+ if (entry.type === "command" && entry.shell === "powershell") {
1161
+ ctx.logger.warn("hook_entries_skipped", { event, type: entry.type, reason: "powershell_not_supported" });
1162
+ return isSyncEntry(entry) ? { kind: "unrun", reason: "powershell_unsupported" } : { kind: "passed" };
1163
+ }
1164
+ if (entry.type === "prompt" || entry.type === "agent") {
1165
+ const carrier = entry.type === "prompt" ? ctx.hookLlm : ctx.hookAgent;
1166
+ if (!carrier) {
1167
+ ctx.logger.warn("hook_entries_skipped", { event, type: entry.type, reason: "llm_carrier_unavailable" });
1168
+ return { kind: "unrun", reason: "llm_carrier_unavailable" };
909
1169
  }
910
- if (Date.now() > deadlineAt) {
911
- ctx.logger.warn("hook_event_deadline_exceeded", { event, budgetSec: MAX_HOOK_EVENT_TOTAL_SECONDS });
912
- return results;
1170
+ if (llmEntries >= MAX_LLM_ENTRIES_PER_EVENT) {
1171
+ ctx.logger.warn("hook_entries_skipped", { event, type: entry.type, reason: "llm_entries_per_event_cap", cap: MAX_LLM_ENTRIES_PER_EVENT });
1172
+ return { kind: "unrun", reason: "llm_cap" };
913
1173
  }
1174
+ }
1175
+ if (entry.type === "command" && (entry.async || entry.asyncRewake)) {
914
1176
  if (entry.once)
915
1177
  onceFired.add(onceKey);
916
- if (entry.type === "prompt" || entry.type === "agent")
917
- llmEntries++;
918
- const run = await runRunnableEntry(entry, payload, ctx, event, llmExtra);
919
- if (run.spawnError) {
920
- ctx.logger.warn(entry.type === "http" ? "hook_http_request_failed" : entry.type === "prompt" || entry.type === "agent" ? "hook_llm_failed" : "hook_command_spawn_failed", { event, type: entry.type, error: clip(run.spawnError, 300) });
921
- if (!run.noticed)
922
- noteHookFailure(ctx, { event, entry, reason: "spawn_failed", exitCode: run.code, stderr: run.spawnError, toolName: toolNameOf(payload) });
923
- continue;
924
- }
925
- if (run.timedOut) {
926
- ctx.logger.warn("hook_command_timeout", { event, timeoutSec: Math.min(entry.timeout ?? DEFAULT_HOOK_TIMEOUT_SECONDS, MAX_HOOK_TIMEOUT_SECONDS) });
927
- if (!run.noticed)
928
- noteHookFailure(ctx, { event, entry, reason: "timeout", exitCode: run.code, stderr: run.stderr, toolName: toolNameOf(payload) });
929
- continue;
1178
+ void fireAsyncCommandHook(entry, payload, ctx, event, halt);
1179
+ return { kind: "passed" };
1180
+ }
1181
+ if (Date.now() > deadlineAt) {
1182
+ ctx.logger.warn("hook_event_deadline_exceeded", { event, budgetSec: MAX_HOOK_EVENT_TOTAL_SECONDS });
1183
+ return { kind: "unrun", reason: "budget_exhausted" };
1184
+ }
1185
+ if (entry.once)
1186
+ onceFired.add(onceKey);
1187
+ if (entry.type === "prompt" || entry.type === "agent")
1188
+ llmEntries++;
1189
+ const run = await runRunnableEntry(entry, payload, ctx, event, llmExtra, halt);
1190
+ if (run.aborted !== undefined) {
1191
+ if (run.aborted.signal !== undefined)
1192
+ ctx.logger.warn("hook_command_killed", { event, type: entry.type, killed: true, signal: run.aborted.signal });
1193
+ return { kind: "halted" };
1194
+ }
1195
+ if (run.spawnError) {
1196
+ ctx.logger.warn(entry.type === "http" ? "hook_http_request_failed" : entry.type === "prompt" || entry.type === "agent" ? "hook_llm_failed" : "hook_command_spawn_failed", { event, type: entry.type, error: echoCallerText(run.spawnError, { maxChars: LOG_ERROR_ECHO_CHARS }) });
1197
+ if (!run.noticed)
1198
+ noteHookFailure(ctx, { event, entry, reason: "spawn_failed", exitCode: run.code, stderr: run.spawnError, ...(toolName !== undefined ? { toolName } : {}) });
1199
+ return { kind: "unrun", reason: "spawn_failed" };
1200
+ }
1201
+ if (run.timedOut) {
1202
+ ctx.logger.warn("hook_command_timeout", { event, timeoutSec: Math.min(entry.timeout ?? DEFAULT_HOOK_TIMEOUT_SECONDS, MAX_HOOK_TIMEOUT_SECONDS) });
1203
+ if (!run.noticed)
1204
+ noteHookFailure(ctx, { event, entry, reason: "timeout", exitCode: run.code, stderr: run.stderr, ...(toolName !== undefined ? { toolName } : {}) });
1205
+ return { kind: "unrun", reason: "timeout" };
1206
+ }
1207
+ if (run.badJson)
1208
+ return { kind: "unrun", reason: "bad_json" };
1209
+ return ifOverBudget ? { kind: "ran", run, overBudget: true } : { kind: "ran", run };
1210
+ };
1211
+ for (const { group, index } of groups) {
1212
+ const selected = matcherGroupSelected(ctx, event, group, matchValue, toolName);
1213
+ if (selected === "invalid") {
1214
+ for (const entry of group.hooks)
1215
+ if (isSyncEntry(entry))
1216
+ ledger.unrun(group.source, entry, "matcher_invalid");
1217
+ continue;
1218
+ }
1219
+ if (selected === false)
1220
+ continue;
1221
+ for (const [ei, entry] of group.hooks.entries()) {
1222
+ const fate = await settle(entry, `${event}:${index}:${ei}`);
1223
+ switch (fate.kind) {
1224
+ case "ran": {
1225
+ const distrust = !PERMISSIVE_VERDICT_EVENTS[event]
1226
+ ? undefined
1227
+ : truncatedFor(entry)
1228
+ ? "input_truncated"
1229
+ : selected === "over_budget" || fate.overBudget === true
1230
+ ? "matcher_over_budget"
1231
+ : undefined;
1232
+ results.push({ entry, run: fate.run, source: group.source, ...(distrust !== undefined ? { distrust } : {}) });
1233
+ if (distrust !== undefined)
1234
+ ledger.untrusted(group.source, entry, distrust);
1235
+ break;
1236
+ }
1237
+ case "unrun":
1238
+ ledger.unrun(group.source, entry, fate.reason);
1239
+ if (fate.reason === "budget_exhausted")
1240
+ return results;
1241
+ break;
1242
+ case "passed":
1243
+ break;
1244
+ case "halted":
1245
+ return results;
1246
+ default: {
1247
+ const unreachable = fate;
1248
+ return unreachable;
1249
+ }
930
1250
  }
931
- results.push({ entry, run });
932
1251
  }
933
1252
  }
934
1253
  return results;
935
1254
  }
1255
+ function createLaneClocks() {
1256
+ const startedAt = {};
1257
+ return (phase) => {
1258
+ const lane = phase === "unmanaged" ? "unmanaged" : "managed";
1259
+ return (startedAt[lane] ??= Date.now() + MAX_HOOK_EVENT_TOTAL_SECONDS * 1000);
1260
+ };
1261
+ }
1262
+ function hookLanesOf(groups) {
1263
+ const indexed = groups.map((group, index) => ({ group, index, phase: hookPhaseOf(group.source) }));
1264
+ return HOOK_PHASES.map((phase) => ({ phase, groups: indexed.filter((g) => g.phase === phase).map(({ group, index }) => ({ group, index })) })).filter((l) => l.groups.length > 0);
1265
+ }
1266
+ function trustRank(phase) {
1267
+ return HOOK_PHASES.length - 1 - HOOK_PHASES.indexOf(phase);
1268
+ }
1269
+ async function runEventLanes(event, groups, matchValue, payload, ctx, onceFired, halt, ledger, llmExtra) {
1270
+ const deadlineFor = createLaneClocks();
1271
+ const ran = [];
1272
+ for (const lane of hookLanesOf(groups)) {
1273
+ if (halt.aborted)
1274
+ break;
1275
+ for (const r of await runMatchingEntries(event, lane.groups, matchValue, payload, ctx, onceFired, halt, deadlineFor(lane.phase), ledger, llmExtra))
1276
+ ran.push({ ...r, phase: lane.phase });
1277
+ }
1278
+ return ran.sort((a, b) => trustRank(a.phase) - trustRank(b.phase)).map(({ entry, run, source }) => ({ entry, run, source }));
1279
+ }
936
1280
  function basePayload(ctx, permissionMode) {
937
1281
  return {
938
1282
  session_id: ctx.sessionId,
@@ -944,27 +1288,20 @@ function basePayload(ctx, permissionMode) {
944
1288
  function ignoreMatchers(groups) {
945
1289
  return groups.map((g) => (g.matcher === undefined ? g : { ...g, matcher: undefined }));
946
1290
  }
947
- async function runObserveOnlyEvent(event, groups, matchValue, payload, ctx, onceFired) {
948
- const singles = await runMatchingEntries(event, groups, matchValue, payload, ctx, onceFired, Date.now() + MAX_HOOK_EVENT_TOTAL_SECONDS * 1000);
949
- for (const { entry, run } of singles) {
1291
+ async function runObserveOnlyEvent(event, groups, matchValue, payload, ctx, onceFired, halt) {
1292
+ const ledger = createUnrunLedger(ctx, event, toolNameOf(payload));
1293
+ const singles = await runEventLanes(event, groups, matchValue, payload, ctx, onceFired, halt, ledger);
1294
+ for (const ran of singles) {
1295
+ const { run } = ran;
950
1296
  if (run.code !== 0) {
951
- ctx.logger.warn("hook_command_failed", { event, code: run.code, stderr: clip(run.stderr, 300) });
952
- noteHookFailure(ctx, {
953
- event,
954
- entry,
955
- reason: "exit_nonzero",
956
- exitCode: run.code,
957
- stderr: run.stderr,
958
- toolName: toolNameOf(payload),
959
- ...(run.code === 2 ? { detail: `exit 2 on ${event}: this event has no blocking face, so the hook's blocking intent could not be honored` } : {}),
960
- });
1297
+ noteExitNonzero(ctx, ledger, event, ran, toolNameOf(payload), run.code === 2 ? `exit 2 on ${event}: this event has no blocking face, so the hook's blocking intent could not be honored` : undefined);
961
1298
  continue;
962
1299
  }
963
- const out = readHookStdout(run, entry, event, ctx, payload);
1300
+ const out = readHookStdout(ran, event, ctx, payload, ledger);
964
1301
  if (!out)
965
1302
  continue;
966
1303
  if (typeof out.systemMessage === "string")
967
- ctx.logger.info("hook_system_message", { event, message: clip(out.systemMessage, 500) });
1304
+ logHookSystemMessage(ctx, event, out.systemMessage);
968
1305
  if (out.decision !== undefined || out.continue === false || out.hookSpecificOutput !== undefined) {
969
1306
  ctx.logger.warn("hook_result_unsupported", { event });
970
1307
  }
@@ -995,98 +1332,129 @@ export function createSettingsHooksRegistration(config, ctx, budgetMs) {
995
1332
  const onceFired = new Set();
996
1333
  const translators = {};
997
1334
  if (pre) {
998
- translators.PreToolUse = async (e) => {
1335
+ translators.PreToolUse = async (e, signal) => {
999
1336
  const toolName = e.tool;
1000
- const input = e.input;
1001
- let currentInput = input;
1337
+ const ledger = createUnrunLedger(ctx, "PreToolUse", toolName);
1338
+ const deadlineFor = createLaneClocks();
1339
+ let currentInput = e.input;
1002
1340
  let rewritten = false;
1003
1341
  let asked;
1004
1342
  let allowedFor;
1005
1343
  let unknownDecision = false;
1006
- const contexts = [];
1007
- const deadlineAt = Date.now() + MAX_HOOK_EVENT_TOTAL_SECONDS * 1000;
1008
- let gi = -1;
1009
- for (const group of pre) {
1010
- gi++;
1011
- const m = hookMatcherMatches(group.matcher, toolName);
1012
- if (m === "invalid") {
1013
- ctx.logger.warn("hook_matcher_invalid", { event: "PreToolUse", matcher: echoCallerText(group.matcher ?? "", { maxChars: MATCHER_ECHO_CHARS }) });
1014
- continue;
1015
- }
1016
- if (!m)
1017
- continue;
1018
- const groupInput = currentInput;
1019
- const payload = {
1020
- ...basePayload(ctx, e.permissionMode),
1021
- hook_event_name: "PreToolUse",
1022
- tool_name: toolName,
1023
- tool_input: groupInput,
1024
- tool_use_id: e.tool_use_id,
1025
- };
1026
- const singles = await runMatchingEntries("PreToolUse", [{ ...group, matcher: undefined }], toolName, payload, ctx, onceFired, deadlineAt, `PreToolUse:${gi}`);
1027
- for (const { entry, run } of singles) {
1028
- if (run.code === 2) {
1029
- const reason = clip(run.stderr.trim(), MAX_HOOK_FEEDBACK_CHARS) || "blocked by a PreToolUse hook";
1030
- return { action: "deny", message: reason, ...buildHookContextField(contexts) };
1031
- }
1032
- if (run.code !== 0) {
1033
- ctx.logger.warn("hook_command_failed", { event: "PreToolUse", code: run.code, stderr: clip(run.stderr, 300) });
1034
- noteHookFailure(ctx, { event: "PreToolUse", entry, reason: "exit_nonzero", exitCode: run.code, stderr: run.stderr, toolName });
1035
- continue;
1036
- }
1037
- const out = readHookStdout(run, entry, "PreToolUse", ctx, payload);
1038
- if (!out)
1039
- continue;
1040
- if (typeof out.systemMessage === "string")
1041
- ctx.logger.info("hook_system_message", { event: "PreToolUse", message: clip(out.systemMessage, 500) });
1042
- if (out.continue === false) {
1043
- const reason = clip(typeof out.stopReason === "string" ? out.stopReason : "hook requested stop", MAX_HOOK_FEEDBACK_CHARS);
1044
- return { action: "deny", message: reason, ...buildHookContextField(contexts) };
1045
- }
1046
- if (out.decision === "block") {
1047
- const reason = clip(typeof out.reason === "string" ? out.reason : "blocked by a PreToolUse hook", MAX_HOOK_FEEDBACK_CHARS);
1048
- return { action: "deny", message: reason, ...buildHookContextField(contexts) };
1049
- }
1050
- const hso = out.hookSpecificOutput;
1051
- if (hso !== null && typeof hso === "object" && hso.hookEventName === "PreToolUse") {
1052
- const h = hso;
1053
- if (typeof h.additionalContext === "string")
1054
- contexts.push(clip(h.additionalContext, MAX_HOOK_FEEDBACK_CHARS));
1055
- const decision = h.permissionDecision;
1056
- if (decision === "deny") {
1057
- const reason = clip(typeof h.permissionDecisionReason === "string" ? h.permissionDecisionReason : "denied by a PreToolUse hook", MAX_HOOK_FEEDBACK_CHARS);
1058
- return { action: "deny", message: reason, ...buildHookContextField(contexts) };
1344
+ const contexts = { unmanaged: [], managedPlugin: [], managedPolicy: [] };
1345
+ const contextField = () => buildHookContextField([...HOOK_PHASES].sort((a, b) => trustRank(a) - trustRank(b)).flatMap((p) => contexts[p]));
1346
+ const runPass = async (phase, groups, once) => {
1347
+ let rewroteBy;
1348
+ for (const laneGroup of groups) {
1349
+ const groupInput = currentInput;
1350
+ const payload = {
1351
+ ...basePayload(ctx, e.permissionMode),
1352
+ hook_event_name: "PreToolUse",
1353
+ tool_name: toolName,
1354
+ tool_input: groupInput,
1355
+ tool_use_id: e.tool_use_id,
1356
+ };
1357
+ const singles = await runMatchingEntries("PreToolUse", [laneGroup], toolName, payload, ctx, once, signal, deadlineFor(phase), ledger);
1358
+ for (const ran of singles) {
1359
+ const { entry, run } = ran;
1360
+ if (run.code === 2) {
1361
+ const reason = clipDataText(run.stderr.trim(), MAX_HOOK_FEEDBACK_CHARS) || "blocked by a PreToolUse hook";
1362
+ return { deny: { action: "deny", message: reason, ...contextField() } };
1059
1363
  }
1060
- if (decision === "ask") {
1061
- asked = clip(typeof h.permissionDecisionReason === "string" ? h.permissionDecisionReason : "a PreToolUse hook requires approval", MAX_HOOK_FEEDBACK_CHARS);
1364
+ if (run.code !== 0) {
1365
+ noteExitNonzero(ctx, ledger, "PreToolUse", ran, toolName);
1366
+ continue;
1062
1367
  }
1063
- if (decision === "allow" || decision === undefined) {
1064
- const rewrites = h.updatedInput !== null && typeof h.updatedInput === "object";
1065
- if (rewrites) {
1066
- currentInput = h.updatedInput;
1067
- rewritten = true;
1068
- }
1069
- if (decision === "allow")
1070
- allowedFor = { input: rewrites ? h.updatedInput : groupInput };
1368
+ const out = readHookStdout(ran, "PreToolUse", ctx, payload, ledger);
1369
+ if (!out)
1370
+ continue;
1371
+ if (typeof out.systemMessage === "string")
1372
+ logHookSystemMessage(ctx, "PreToolUse", out.systemMessage);
1373
+ if (out.continue === false) {
1374
+ const reason = clipDataText(typeof out.stopReason === "string" ? out.stopReason : "hook requested stop", MAX_HOOK_FEEDBACK_CHARS);
1375
+ return { deny: { action: "deny", message: reason, ...contextField() } };
1071
1376
  }
1072
- else if (decision !== "deny" && decision !== "ask") {
1073
- unknownDecision = true;
1074
- ctx.logger.warn("hook_permission_decision_unknown", {
1075
- event: "PreToolUse",
1076
- decision: clip(typeof decision === "string" ? decision : typeof decision, 200),
1077
- tool: toolName,
1078
- effect: "hook ruling not honored (closed word set is allow|deny|ask); this call falls back to the engine gate",
1079
- });
1377
+ if (out.decision === "block") {
1378
+ const reason = clipDataText(typeof out.reason === "string" ? out.reason : "blocked by a PreToolUse hook", MAX_HOOK_FEEDBACK_CHARS);
1379
+ return { deny: { action: "deny", message: reason, ...contextField() } };
1380
+ }
1381
+ const hso = out.hookSpecificOutput;
1382
+ if (hso !== null && typeof hso === "object" && hso.hookEventName === "PreToolUse") {
1383
+ const h = hso;
1384
+ if (typeof h.additionalContext === "string")
1385
+ contexts[phase].push(clipDataText(h.additionalContext, MAX_HOOK_FEEDBACK_CHARS));
1386
+ const decision = h.permissionDecision;
1387
+ if (decision === "deny") {
1388
+ const reason = clipDataText(typeof h.permissionDecisionReason === "string" ? h.permissionDecisionReason : "denied by a PreToolUse hook", MAX_HOOK_FEEDBACK_CHARS);
1389
+ return { deny: { action: "deny", message: reason, ...contextField() } };
1390
+ }
1391
+ if (decision === "ask") {
1392
+ asked = clipDataText(typeof h.permissionDecisionReason === "string" ? h.permissionDecisionReason : "a PreToolUse hook requires approval", MAX_HOOK_FEEDBACK_CHARS);
1393
+ }
1394
+ if (decision === "allow" || decision === undefined) {
1395
+ const proposed = h.updatedInput !== null && typeof h.updatedInput === "object" ? h.updatedInput : undefined;
1396
+ const proposesRewrite = proposed !== undefined && canonicalJson(proposed) !== canonicalJson(groupInput);
1397
+ if (ran.distrust !== undefined && hookPhaseOf(ran.source) === "unmanaged" && (decision === "allow" || proposesRewrite)) {
1398
+ noteVerdictUntrusted(ctx, { event: "PreToolUse", reason: ran.distrust, entry, source: ran.source, toolName });
1399
+ }
1400
+ const offered = ran.distrust === undefined ? proposed : undefined;
1401
+ const rewrites = offered !== undefined && proposesRewrite;
1402
+ if (rewrites) {
1403
+ currentInput = offered;
1404
+ rewritten = true;
1405
+ rewroteBy = ran;
1406
+ }
1407
+ if (decision === "allow" && ran.distrust === undefined)
1408
+ allowedFor = { input: rewrites ? offered : groupInput };
1409
+ }
1410
+ else if (decision !== "deny" && decision !== "ask") {
1411
+ unknownDecision = true;
1412
+ ctx.logger.warn("hook_permission_decision_unknown", {
1413
+ event: "PreToolUse",
1414
+ decision: echoCallerText(typeof decision === "string" ? decision : typeof decision, { maxChars: LOG_PREVIEW_ECHO_CHARS }),
1415
+ tool: toolName,
1416
+ effect: "hook ruling not honored (closed word set is allow|deny|ask); this call falls back to the engine gate",
1417
+ });
1418
+ ledger.unrun(ran.source, entry, "decision_out_of_set");
1419
+ }
1080
1420
  }
1081
1421
  }
1082
1422
  }
1423
+ return { rewroteBy };
1424
+ };
1425
+ for (const lane of hookLanesOf(pre)) {
1426
+ if (lane.phase === "unmanaged") {
1427
+ const pass = await runPass(lane.phase, lane.groups, onceFired);
1428
+ if (pass.deny !== undefined)
1429
+ return pass.deny;
1430
+ continue;
1431
+ }
1432
+ const onceBefore = new Set(onceFired);
1433
+ const first = await runPass(lane.phase, lane.groups, onceFired);
1434
+ if (first.deny !== undefined)
1435
+ return first.deny;
1436
+ if (first.rewroteBy === undefined)
1437
+ continue;
1438
+ contexts[lane.phase].length = 0;
1439
+ const rejudgeOnce = new Set(onceBefore);
1440
+ const second = await runPass(lane.phase, lane.groups, rejudgeOnce);
1441
+ for (const key of rejudgeOnce)
1442
+ onceFired.add(key);
1443
+ if (second.deny !== undefined)
1444
+ return second.deny;
1445
+ if (second.rewroteBy !== undefined) {
1446
+ ledger.unrun(second.rewroteBy.source, second.rewroteBy.entry, "rewrite_unsettled", "the managed hooks of this phase rewrote the tool input again after being re-run on their own rewrite; approval required");
1447
+ }
1083
1448
  }
1084
1449
  if (asked !== undefined) {
1450
+ return { action: "ask", message: asked, ...(rewritten ? { updatedInput: currentInput } : {}), ...contextField() };
1451
+ }
1452
+ if (ledger.managedWithoutVerdict) {
1085
1453
  return {
1086
1454
  action: "ask",
1087
- message: asked,
1455
+ message: "a managed PreToolUse hook did not run to a verdict — approval required",
1088
1456
  ...(rewritten ? { updatedInput: currentInput } : {}),
1089
- ...buildHookContextField(contexts),
1457
+ ...contextField(),
1090
1458
  };
1091
1459
  }
1092
1460
  if (unknownDecision) {
@@ -1094,19 +1462,19 @@ export function createSettingsHooksRegistration(config, ctx, budgetMs) {
1094
1462
  action: "ask",
1095
1463
  message: "a PreToolUse hook returned an unrecognized permissionDecision — approval required",
1096
1464
  ...(rewritten ? { updatedInput: currentInput } : {}),
1097
- ...buildHookContextField(contexts),
1465
+ ...contextField(),
1098
1466
  };
1099
1467
  }
1100
- const carried = { ...(rewritten ? { updatedInput: currentInput } : {}), ...buildHookContextField(contexts) };
1468
+ const carried = { ...(rewritten ? { updatedInput: currentInput } : {}), ...contextField() };
1101
1469
  if (allowedFor !== undefined && allowedFor.input === currentInput)
1102
1470
  return { action: "allow", ...carried };
1103
- if (rewritten || contexts.length > 0)
1471
+ if (rewritten || HOOK_PHASES.some((p) => contexts[p].length > 0))
1104
1472
  return carried;
1105
1473
  return undefined;
1106
1474
  };
1107
1475
  }
1108
1476
  if (post) {
1109
- translators.PostToolUse = async (e) => {
1477
+ translators.PostToolUse = async (e, signal) => {
1110
1478
  const toolName = e.tool;
1111
1479
  const input = e.input;
1112
1480
  const output = e.output;
@@ -1119,37 +1487,38 @@ export function createSettingsHooksRegistration(config, ctx, budgetMs) {
1119
1487
  hook_event_name: "PostToolUse",
1120
1488
  tool_name: toolName,
1121
1489
  tool_input: input,
1122
- tool_response: { isError: output.isError, text: clip(responseText, MAX_TOOL_RESPONSE_CHARS) },
1490
+ tool_response: { isError: output.isError, text: clipDataText(responseText, MAX_TOOL_RESPONSE_CHARS) },
1123
1491
  tool_use_id: e.tool_use_id,
1124
1492
  };
1125
- const singles = await runMatchingEntries("PostToolUse", post, toolName, payload, ctx, onceFired, Date.now() + MAX_HOOK_EVENT_TOTAL_SECONDS * 1000);
1126
- for (const { entry, run } of singles) {
1493
+ const ledger = createUnrunLedger(ctx, "PostToolUse", toolName);
1494
+ const singles = await runEventLanes("PostToolUse", post, toolName, payload, ctx, onceFired, signal, ledger);
1495
+ for (const ran of singles) {
1496
+ const { entry, run } = ran;
1127
1497
  if (run.code === 2) {
1128
- const fb = clip(run.stderr.trim(), MAX_HOOK_FEEDBACK_CHARS);
1498
+ const fb = clipDataText(run.stderr.trim(), MAX_HOOK_FEEDBACK_CHARS);
1129
1499
  if (fb)
1130
1500
  contexts.push(fb);
1131
1501
  continue;
1132
1502
  }
1133
1503
  if (run.code !== 0) {
1134
- ctx.logger.warn("hook_command_failed", { event: "PostToolUse", code: run.code, stderr: clip(run.stderr, 300) });
1135
- noteHookFailure(ctx, { event: "PostToolUse", entry, reason: "exit_nonzero", exitCode: run.code, stderr: run.stderr, toolName });
1504
+ noteExitNonzero(ctx, ledger, "PostToolUse", ran, toolName);
1136
1505
  continue;
1137
1506
  }
1138
- const out = readHookStdout(run, entry, "PostToolUse", ctx, payload);
1507
+ const out = readHookStdout(ran, "PostToolUse", ctx, payload, ledger);
1139
1508
  if (!out)
1140
1509
  continue;
1141
1510
  if (typeof out.systemMessage === "string")
1142
- ctx.logger.info("hook_system_message", { event: "PostToolUse", message: clip(out.systemMessage, 500) });
1511
+ logHookSystemMessage(ctx, "PostToolUse", out.systemMessage);
1143
1512
  if (out.continue === false) {
1144
1513
  ctx.logger.warn("hook_continue_false_unsupported", { event: "PostToolUse" });
1145
1514
  }
1146
1515
  if (out.decision === "block" && typeof out.reason === "string")
1147
- contexts.push(clip(out.reason, MAX_HOOK_FEEDBACK_CHARS));
1516
+ contexts.push(clipDataText(out.reason, MAX_HOOK_FEEDBACK_CHARS));
1148
1517
  const hso = out.hookSpecificOutput;
1149
1518
  if (hso !== null && typeof hso === "object" && hso.hookEventName === "PostToolUse") {
1150
1519
  const h = hso;
1151
1520
  if (typeof h.additionalContext === "string")
1152
- contexts.push(clip(h.additionalContext, MAX_HOOK_FEEDBACK_CHARS));
1521
+ contexts.push(clipDataText(h.additionalContext, MAX_HOOK_FEEDBACK_CHARS));
1153
1522
  if (h.updatedMCPToolOutput !== undefined) {
1154
1523
  ctx.logger.warn("hook_updated_mcp_output_unsupported", { event: "PostToolUse" });
1155
1524
  }
@@ -1160,7 +1529,7 @@ export function createSettingsHooksRegistration(config, ctx, budgetMs) {
1160
1529
  };
1161
1530
  }
1162
1531
  if (postFailure) {
1163
- translators.PostToolUseFailure = async (e) => {
1532
+ translators.PostToolUseFailure = async (e, signal) => {
1164
1533
  const toolName = e.tool;
1165
1534
  const f = e.failure;
1166
1535
  const contexts = [];
@@ -1170,36 +1539,37 @@ export function createSettingsHooksRegistration(config, ctx, budgetMs) {
1170
1539
  tool_name: toolName,
1171
1540
  tool_input: e.input,
1172
1541
  tool_use_id: e.tool_use_id,
1173
- error: clip(f.error, MAX_TOOL_RESPONSE_CHARS),
1542
+ error: clipDataText(f.error, MAX_TOOL_RESPONSE_CHARS),
1174
1543
  is_interrupt: f.isInterrupt,
1175
1544
  };
1176
- const singles = await runMatchingEntries("PostToolUseFailure", postFailure, toolName, payload, ctx, onceFired, Date.now() + MAX_HOOK_EVENT_TOTAL_SECONDS * 1000);
1177
- for (const { entry, run } of singles) {
1545
+ const ledger = createUnrunLedger(ctx, "PostToolUseFailure", toolName);
1546
+ const singles = await runEventLanes("PostToolUseFailure", postFailure, toolName, payload, ctx, onceFired, signal, ledger);
1547
+ for (const ran of singles) {
1548
+ const { entry, run } = ran;
1178
1549
  if (run.code === 2) {
1179
- const fb = clip(run.stderr.trim(), MAX_HOOK_FEEDBACK_CHARS);
1550
+ const fb = clipDataText(run.stderr.trim(), MAX_HOOK_FEEDBACK_CHARS);
1180
1551
  if (fb)
1181
1552
  contexts.push(fb);
1182
1553
  continue;
1183
1554
  }
1184
1555
  if (run.code !== 0) {
1185
- ctx.logger.warn("hook_command_failed", { event: "PostToolUseFailure", code: run.code, stderr: clip(run.stderr, 300) });
1186
- noteHookFailure(ctx, { event: "PostToolUseFailure", entry, reason: "exit_nonzero", exitCode: run.code, stderr: run.stderr, toolName });
1556
+ noteExitNonzero(ctx, ledger, "PostToolUseFailure", ran, toolName);
1187
1557
  continue;
1188
1558
  }
1189
- const out = readHookStdout(run, entry, "PostToolUseFailure", ctx, payload);
1559
+ const out = readHookStdout(ran, "PostToolUseFailure", ctx, payload, ledger);
1190
1560
  if (!out)
1191
1561
  continue;
1192
1562
  if (typeof out.systemMessage === "string")
1193
- ctx.logger.info("hook_system_message", { event: "PostToolUseFailure", message: clip(out.systemMessage, 500) });
1563
+ logHookSystemMessage(ctx, "PostToolUseFailure", out.systemMessage);
1194
1564
  if (out.continue === false)
1195
1565
  ctx.logger.warn("hook_continue_false_unsupported", { event: "PostToolUseFailure" });
1196
1566
  if (out.decision === "block" && typeof out.reason === "string")
1197
- contexts.push(clip(out.reason, MAX_HOOK_FEEDBACK_CHARS));
1567
+ contexts.push(clipDataText(out.reason, MAX_HOOK_FEEDBACK_CHARS));
1198
1568
  const hso = out.hookSpecificOutput;
1199
1569
  if (hso !== null && typeof hso === "object" && hso.hookEventName === "PostToolUseFailure") {
1200
1570
  const h = hso;
1201
1571
  if (typeof h.additionalContext === "string")
1202
- contexts.push(clip(h.additionalContext, MAX_HOOK_FEEDBACK_CHARS));
1572
+ contexts.push(clipDataText(h.additionalContext, MAX_HOOK_FEEDBACK_CHARS));
1203
1573
  }
1204
1574
  }
1205
1575
  const additionalContext = buildHookContext(contexts);
@@ -1207,7 +1577,7 @@ export function createSettingsHooksRegistration(config, ctx, budgetMs) {
1207
1577
  };
1208
1578
  }
1209
1579
  if (postBatch) {
1210
- translators.PostToolBatch = async (e) => {
1580
+ translators.PostToolBatch = async (e, signal) => {
1211
1581
  const contexts = [];
1212
1582
  const payload = {
1213
1583
  ...basePayload(ctx, e.permissionMode),
@@ -1216,31 +1586,32 @@ export function createSettingsHooksRegistration(config, ctx, budgetMs) {
1216
1586
  tool_name: c.toolName,
1217
1587
  tool_input: boundedInputValue(c.input),
1218
1588
  tool_use_id: c.toolCallId,
1219
- tool_response: { isError: c.isError, ...(c.response !== undefined ? { text: clip(c.response, MAX_TOOL_RESPONSE_CHARS) } : {}) },
1589
+ tool_response: { isError: c.isError, ...(c.response !== undefined ? { text: clipDataText(c.response, MAX_TOOL_RESPONSE_CHARS) } : {}) },
1220
1590
  })),
1221
1591
  };
1222
- const singles = await runMatchingEntries("PostToolBatch", ignoreMatchers(postBatch), "", payload, ctx, onceFired, Date.now() + MAX_HOOK_EVENT_TOTAL_SECONDS * 1000);
1223
- for (const { entry, run } of singles) {
1592
+ const ledger = createUnrunLedger(ctx, "PostToolBatch", undefined);
1593
+ const singles = await runEventLanes("PostToolBatch", ignoreMatchers(postBatch), "", payload, ctx, onceFired, signal, ledger);
1594
+ for (const ran of singles) {
1595
+ const { entry, run } = ran;
1224
1596
  if (run.code === 2) {
1225
- const fb = clip(run.stderr.trim(), MAX_HOOK_FEEDBACK_CHARS);
1597
+ const fb = clipDataText(run.stderr.trim(), MAX_HOOK_FEEDBACK_CHARS);
1226
1598
  if (fb)
1227
1599
  contexts.push(fb);
1228
1600
  continue;
1229
1601
  }
1230
1602
  if (run.code !== 0) {
1231
- ctx.logger.warn("hook_command_failed", { event: "PostToolBatch", code: run.code, stderr: clip(run.stderr, 300) });
1232
- noteHookFailure(ctx, { event: "PostToolBatch", entry, reason: "exit_nonzero", exitCode: run.code, stderr: run.stderr });
1603
+ noteExitNonzero(ctx, ledger, "PostToolBatch", ran);
1233
1604
  continue;
1234
1605
  }
1235
- const out = readHookStdout(run, entry, "PostToolBatch", ctx, payload);
1606
+ const out = readHookStdout(ran, "PostToolBatch", ctx, payload, ledger);
1236
1607
  if (!out)
1237
1608
  continue;
1238
1609
  if (typeof out.systemMessage === "string")
1239
- ctx.logger.info("hook_system_message", { event: "PostToolBatch", message: clip(out.systemMessage, 500) });
1610
+ logHookSystemMessage(ctx, "PostToolBatch", out.systemMessage);
1240
1611
  if (out.continue === false)
1241
1612
  ctx.logger.warn("hook_continue_false_unsupported", { event: "PostToolBatch" });
1242
1613
  if (out.decision === "block" && typeof out.reason === "string")
1243
- contexts.push(clip(out.reason, MAX_HOOK_FEEDBACK_CHARS));
1614
+ contexts.push(clipDataText(out.reason, MAX_HOOK_FEEDBACK_CHARS));
1244
1615
  if (out.hookSpecificOutput !== undefined)
1245
1616
  ctx.logger.warn("hook_result_unsupported", { event: "PostToolBatch" });
1246
1617
  }
@@ -1249,38 +1620,39 @@ export function createSettingsHooksRegistration(config, ctx, budgetMs) {
1249
1620
  };
1250
1621
  }
1251
1622
  if (promptSubmit) {
1252
- translators.UserPromptSubmit = async (e) => {
1623
+ translators.UserPromptSubmit = async (e, signal) => {
1253
1624
  const contexts = [];
1254
- const payload = { ...basePayload(ctx, e.permissionMode), hook_event_name: "UserPromptSubmit", prompt: clip(e.prompt, MAX_TOOL_INPUT_CHARS) };
1255
- const singles = await runMatchingEntries("UserPromptSubmit", ignoreMatchers(promptSubmit), "", payload, ctx, onceFired, Date.now() + MAX_HOOK_EVENT_TOTAL_SECONDS * 1000);
1256
- for (const { entry, run } of singles) {
1625
+ const payload = { ...basePayload(ctx, e.permissionMode), hook_event_name: "UserPromptSubmit", prompt: clipDataText(e.prompt, MAX_TOOL_INPUT_CHARS) };
1626
+ const ledger = createUnrunLedger(ctx, "UserPromptSubmit", undefined);
1627
+ const singles = await runEventLanes("UserPromptSubmit", ignoreMatchers(promptSubmit), "", payload, ctx, onceFired, signal, ledger);
1628
+ for (const ran of singles) {
1629
+ const { entry, run } = ran;
1257
1630
  if (run.code === 2) {
1258
- const reason = clip(run.stderr.trim(), MAX_HOOK_FEEDBACK_CHARS) || "blocked by a UserPromptSubmit hook";
1631
+ const reason = clipDataText(run.stderr.trim(), MAX_HOOK_FEEDBACK_CHARS) || "blocked by a UserPromptSubmit hook";
1259
1632
  return { block: reason, ...buildHookContextField(contexts) };
1260
1633
  }
1261
1634
  if (run.code !== 0) {
1262
- ctx.logger.warn("hook_command_failed", { event: "UserPromptSubmit", code: run.code, stderr: clip(run.stderr, 300) });
1263
- noteHookFailure(ctx, { event: "UserPromptSubmit", entry, reason: "exit_nonzero", exitCode: run.code, stderr: run.stderr });
1635
+ noteExitNonzero(ctx, ledger, "UserPromptSubmit", ran);
1264
1636
  continue;
1265
1637
  }
1266
- const out = readHookStdout(run, entry, "UserPromptSubmit", ctx, payload);
1638
+ const out = readHookStdout(ran, "UserPromptSubmit", ctx, payload, ledger);
1267
1639
  if (!out)
1268
1640
  continue;
1269
1641
  if (typeof out.systemMessage === "string")
1270
- ctx.logger.info("hook_system_message", { event: "UserPromptSubmit", message: clip(out.systemMessage, 500) });
1642
+ logHookSystemMessage(ctx, "UserPromptSubmit", out.systemMessage);
1271
1643
  if (out.continue === false) {
1272
- const reason = clip(typeof out.stopReason === "string" ? out.stopReason : "hook requested stop", MAX_HOOK_FEEDBACK_CHARS);
1644
+ const reason = clipDataText(typeof out.stopReason === "string" ? out.stopReason : "hook requested stop", MAX_HOOK_FEEDBACK_CHARS);
1273
1645
  return { block: reason, ...buildHookContextField(contexts) };
1274
1646
  }
1275
1647
  if (out.decision === "block") {
1276
- const reason = clip(typeof out.reason === "string" ? out.reason : "blocked by a UserPromptSubmit hook", MAX_HOOK_FEEDBACK_CHARS);
1648
+ const reason = clipDataText(typeof out.reason === "string" ? out.reason : "blocked by a UserPromptSubmit hook", MAX_HOOK_FEEDBACK_CHARS);
1277
1649
  return { block: reason, ...buildHookContextField(contexts) };
1278
1650
  }
1279
1651
  const hso = out.hookSpecificOutput;
1280
1652
  if (hso !== null && typeof hso === "object" && hso.hookEventName === "UserPromptSubmit") {
1281
1653
  const h = hso;
1282
1654
  if (typeof h.additionalContext === "string")
1283
- contexts.push(clip(h.additionalContext, MAX_HOOK_FEEDBACK_CHARS));
1655
+ contexts.push(clipDataText(h.additionalContext, MAX_HOOK_FEEDBACK_CHARS));
1284
1656
  }
1285
1657
  }
1286
1658
  const additionalContext = buildHookContext(contexts);
@@ -1288,7 +1660,7 @@ export function createSettingsHooksRegistration(config, ctx, budgetMs) {
1288
1660
  };
1289
1661
  }
1290
1662
  if (stopEntries) {
1291
- translators.Stop = async (sctx) => {
1663
+ translators.Stop = async (sctx, signal) => {
1292
1664
  const payload = { ...basePayload(ctx, sctx.permissionMode), hook_event_name: "Stop", stop_hook_active: sctx.stopHookActive };
1293
1665
  let llmExtra;
1294
1666
  const wantsPrompt = stopEntries.some((g) => g.hooks.some((h) => h.type === "prompt"));
@@ -1303,7 +1675,7 @@ export function createSettingsHooksRegistration(config, ctx, budgetMs) {
1303
1675
  branch = await sctx.getBranch?.();
1304
1676
  }
1305
1677
  catch (e) {
1306
- ctx.logger.warn("hook_stop_branch_failed", { error: e instanceof Error ? e.message : String(e) });
1678
+ ctx.logger.warn("hook_stop_branch_failed", { error: echoCallerText(e instanceof Error ? e.message : String(e), { maxChars: LOG_ERROR_ECHO_CHARS }) });
1307
1679
  }
1308
1680
  const rendered = renderBranchTranscript(branch);
1309
1681
  if (rendered.sufficient) {
@@ -1316,22 +1688,29 @@ export function createSettingsHooksRegistration(config, ctx, budgetMs) {
1316
1688
  }
1317
1689
  }
1318
1690
  const stopGroups = ignoreMatchers(stopEntries);
1691
+ const ledger = createUnrunLedger(ctx, "Stop", undefined);
1692
+ if (!llmExtra && wantsPrompt) {
1693
+ for (const g of stopGroups)
1694
+ for (const h of g.hooks)
1695
+ if (h.type === "prompt")
1696
+ ledger.unrun(g.source, h, "evidence_unavailable", ctx.stopPromptTranscript === false ? "transcript disabled" : "no transcript evidence");
1697
+ }
1319
1698
  const effectiveGroups = llmExtra || !wantsPrompt
1320
1699
  ? stopGroups
1321
1700
  : stopGroups.map((g) => ({ ...g, hooks: g.hooks.filter((h) => h.type !== "prompt") })).filter((g) => g.hooks.length > 0);
1322
- const singles = await runMatchingEntries("Stop", effectiveGroups, "", payload, ctx, onceFired, Date.now() + MAX_HOOK_EVENT_TOTAL_SECONDS * 1000, undefined, llmExtra);
1701
+ const singles = await runEventLanes("Stop", effectiveGroups, "", payload, ctx, onceFired, signal, ledger, llmExtra);
1323
1702
  let block;
1324
1703
  let sawContinueFalse = false;
1325
1704
  const contexts = [];
1326
- for (const { entry, run } of singles) {
1705
+ for (const ran of singles) {
1706
+ const { entry, run } = ran;
1327
1707
  if (run.code === 2) {
1328
1708
  if (block === undefined)
1329
- block = clip(run.stderr.trim(), MAX_HOOK_FEEDBACK_CHARS) || "blocked by a Stop hook";
1709
+ block = clipDataText(run.stderr.trim(), MAX_HOOK_FEEDBACK_CHARS) || "blocked by a Stop hook";
1330
1710
  continue;
1331
1711
  }
1332
1712
  if (run.code !== 0) {
1333
- ctx.logger.warn("hook_command_failed", { event: "Stop", code: run.code, stderr: clip(run.stderr, 300) });
1334
- noteHookFailure(ctx, { event: "Stop", entry, reason: "exit_nonzero", exitCode: run.code, stderr: run.stderr });
1713
+ noteExitNonzero(ctx, ledger, "Stop", ran);
1335
1714
  continue;
1336
1715
  }
1337
1716
  if (llmExtra && entry.type === "prompt" && parseCcVerdict(run.stdout)) {
@@ -1339,33 +1718,34 @@ export function createSettingsHooksRegistration(config, ctx, budgetMs) {
1339
1718
  if (v.ok)
1340
1719
  continue;
1341
1720
  if (v.impossible) {
1342
- ctx.logger.warn("hook_stop_condition_impossible", { event: "Stop", reason: clip(v.reason?.trim() ?? "", 500) });
1721
+ ctx.logger.warn("hook_stop_condition_impossible", { event: "Stop", reason: echoCallerText(v.reason?.trim() ?? "", { maxChars: LOG_MESSAGE_ECHO_CHARS }) });
1343
1722
  continue;
1344
1723
  }
1345
1724
  if (block === undefined)
1346
- block = clip(v.reason?.trim() || "stop condition not satisfied", MAX_HOOK_FEEDBACK_CHARS);
1725
+ block = clipDataText(v.reason?.trim() || "stop condition not satisfied", MAX_HOOK_FEEDBACK_CHARS);
1347
1726
  continue;
1348
1727
  }
1349
- const out = llmExtra && entry.type === "prompt" ? parseHookStdout(run.stdout) : readHookStdout(run, entry, "Stop", ctx, payload);
1728
+ const out = llmExtra && entry.type === "prompt" ? parseHookStdout(run.stdout) : readHookStdout(ran, "Stop", ctx, payload, ledger);
1350
1729
  if (!out) {
1351
1730
  if (llmExtra && entry.type === "prompt") {
1352
- ctx.logger.warn("hook_llm_unparsed_verdict", { event: "Stop", head: clip(run.stdout.trim(), 200) });
1731
+ ctx.logger.warn("hook_llm_unparsed_verdict", { event: "Stop", head: echoCallerText(run.stdout.trim(), { maxChars: LOG_PREVIEW_ECHO_CHARS }) });
1353
1732
  emitHookNotice(ctx, { kind: "hook_decision_unavailable", event: "Stop", reason: "unparsed" });
1733
+ ledger.unrun(ran.source, entry, "bad_json", "the prompt hook's answer could not be read as a verdict");
1354
1734
  }
1355
1735
  continue;
1356
1736
  }
1357
1737
  if (typeof out.systemMessage === "string")
1358
- ctx.logger.info("hook_system_message", { event: "Stop", message: clip(out.systemMessage, 500) });
1738
+ logHookSystemMessage(ctx, "Stop", out.systemMessage);
1359
1739
  if (out.continue === false)
1360
1740
  sawContinueFalse = true;
1361
1741
  if (out.decision === "block" && block === undefined) {
1362
- block = clip(typeof out.reason === "string" ? out.reason : "blocked by a Stop hook", MAX_HOOK_FEEDBACK_CHARS);
1742
+ block = clipDataText(typeof out.reason === "string" ? out.reason : "blocked by a Stop hook", MAX_HOOK_FEEDBACK_CHARS);
1363
1743
  }
1364
1744
  const hso = out.hookSpecificOutput;
1365
1745
  if (hso !== null && typeof hso === "object" && hso.hookEventName === "Stop") {
1366
1746
  const h = hso;
1367
1747
  if (typeof h.additionalContext === "string")
1368
- contexts.push(clip(h.additionalContext, MAX_HOOK_FEEDBACK_CHARS));
1748
+ contexts.push(clipDataText(h.additionalContext, MAX_HOOK_FEEDBACK_CHARS));
1369
1749
  }
1370
1750
  }
1371
1751
  if (sawContinueFalse)
@@ -1379,59 +1759,60 @@ export function createSettingsHooksRegistration(config, ctx, budgetMs) {
1379
1759
  };
1380
1760
  }
1381
1761
  if (stopFailureEntries) {
1382
- translators.StopFailure = async (fctx) => {
1762
+ translators.StopFailure = async (fctx, signal) => {
1383
1763
  const turnError = toAssistantTurnError(fctx.errorKind);
1384
1764
  const payload = {
1385
1765
  ...basePayload(ctx, undefined),
1386
1766
  hook_event_name: "StopFailure",
1387
1767
  error: turnError,
1388
- ...(fctx.error ? { error_details: clip(redactSecrets(fctx.error), MAX_HOOK_FEEDBACK_CHARS) } : {}),
1768
+ ...(fctx.error ? { error_details: clipDataText(redactSecrets(fctx.error), MAX_HOOK_FEEDBACK_CHARS) } : {}),
1389
1769
  };
1390
- await runObserveOnlyEvent("StopFailure", stopFailureEntries, turnError, payload, ctx, onceFired);
1770
+ await runObserveOnlyEvent("StopFailure", stopFailureEntries, turnError, payload, ctx, onceFired, signal);
1391
1771
  };
1392
1772
  }
1393
1773
  if (preCompactEntries) {
1394
- translators.PreCompact = async (pctx) => {
1774
+ translators.PreCompact = async (pctx, signal) => {
1395
1775
  const payload = {
1396
1776
  ...basePayload(ctx, undefined),
1397
1777
  hook_event_name: "PreCompact",
1398
1778
  trigger: pctx.trigger,
1399
1779
  custom_instructions: pctx.customInstructions ?? null,
1400
1780
  };
1401
- const singles = await runMatchingEntries("PreCompact", preCompactEntries, pctx.trigger, payload, ctx, onceFired, Date.now() + MAX_HOOK_EVENT_TOTAL_SECONDS * 1000);
1402
- for (const { entry, run } of singles) {
1781
+ const ledger = createUnrunLedger(ctx, "PreCompact", undefined);
1782
+ const singles = await runEventLanes("PreCompact", preCompactEntries, pctx.trigger, payload, ctx, onceFired, signal, ledger);
1783
+ for (const ran of singles) {
1784
+ const { entry, run } = ran;
1403
1785
  if (run.code === 2) {
1404
- return { block: clip(run.stderr.trim(), MAX_HOOK_FEEDBACK_CHARS) || "blocked by a PreCompact hook" };
1786
+ return { block: clipDataText(run.stderr.trim(), MAX_HOOK_FEEDBACK_CHARS) || "blocked by a PreCompact hook" };
1405
1787
  }
1406
1788
  if (run.code !== 0) {
1407
- ctx.logger.warn("hook_command_failed", { event: "PreCompact", code: run.code, stderr: clip(run.stderr, 300) });
1408
- noteHookFailure(ctx, { event: "PreCompact", entry, reason: "exit_nonzero", exitCode: run.code, stderr: run.stderr });
1789
+ noteExitNonzero(ctx, ledger, "PreCompact", ran);
1409
1790
  continue;
1410
1791
  }
1411
- const out = readHookStdout(run, entry, "PreCompact", ctx, payload);
1792
+ const out = readHookStdout(ran, "PreCompact", ctx, payload, ledger);
1412
1793
  if (!out)
1413
1794
  continue;
1414
1795
  if (typeof out.systemMessage === "string")
1415
- ctx.logger.info("hook_system_message", { event: "PreCompact", message: clip(out.systemMessage, 500) });
1796
+ logHookSystemMessage(ctx, "PreCompact", out.systemMessage);
1416
1797
  if (out.continue === false) {
1417
1798
  ctx.logger.warn("hook_continue_false_unsupported", { event: "PreCompact" });
1418
1799
  }
1419
1800
  if (out.decision === "block") {
1420
- return { block: clip(typeof out.reason === "string" ? out.reason : "blocked by a PreCompact hook", MAX_HOOK_FEEDBACK_CHARS) };
1801
+ return { block: clipDataText(typeof out.reason === "string" ? out.reason : "blocked by a PreCompact hook", MAX_HOOK_FEEDBACK_CHARS) };
1421
1802
  }
1422
1803
  }
1423
1804
  return undefined;
1424
1805
  };
1425
1806
  }
1426
1807
  if (postCompactEntries) {
1427
- translators.PostCompact = async (cctx) => {
1808
+ translators.PostCompact = async (cctx, signal) => {
1428
1809
  const payload = {
1429
1810
  ...basePayload(ctx, undefined),
1430
1811
  hook_event_name: "PostCompact",
1431
1812
  trigger: cctx.trigger,
1432
- compact_summary: clip(cctx.summary, MAX_TOOL_RESPONSE_CHARS),
1813
+ compact_summary: clipDataText(cctx.summary, MAX_TOOL_RESPONSE_CHARS),
1433
1814
  };
1434
- await runObserveOnlyEvent("PostCompact", postCompactEntries, cctx.trigger, payload, ctx, onceFired);
1815
+ await runObserveOnlyEvent("PostCompact", postCompactEntries, cctx.trigger, payload, ctx, onceFired, signal);
1435
1816
  };
1436
1817
  }
1437
1818
  const events = Object.keys(translators);