@iowarp/clio-coder 0.3.4 → 0.3.6

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 (265) hide show
  1. package/CHANGELOG.md +37 -2
  2. package/CONTRIBUTING.md +6 -6
  3. package/README.md +2 -2
  4. package/dist/{acp-S5R4RR5B.js → acp-2BEHC4DL.js} +4 -4
  5. package/dist/{agents-P6DMMVZY.js → agents-LNNFTM53.js} +13 -11
  6. package/dist/assets/codewiki.json +1 -1
  7. package/dist/{auth-2XCZLPKS.js → auth-KXXFI2VS.js} +6 -6
  8. package/dist/{chunk-YCWGATWI.js → chunk-24I7BN55.js} +2 -2
  9. package/dist/{chunk-EKMEHE4H.js → chunk-33YXPOE3.js} +2 -3
  10. package/dist/chunk-3BPUFZDL.js +37 -0
  11. package/dist/{chunk-WPQLXFOZ.js → chunk-43AOLP7E.js} +2 -2
  12. package/dist/{chunk-N4CZJQRK.js → chunk-5JGRAMKL.js} +4 -4
  13. package/dist/{chunk-BRXQQJFP.js → chunk-6US73PDB.js} +568 -47
  14. package/dist/{chunk-K6WL7QZT.js → chunk-6XXKFVSN.js} +2 -2
  15. package/dist/{chunk-QQK64KLB.js → chunk-CJUB2JJ2.js} +138 -20
  16. package/dist/{chunk-HV5X7OR2.js → chunk-CKXWIANG.js} +12 -12
  17. package/dist/{chunk-UZHIZC5S.js → chunk-CYQKWTG3.js} +61 -76
  18. package/dist/{chunk-QWU7ZBO7.js → chunk-DJVECN66.js} +204 -45
  19. package/dist/{chunk-ZWMF7253.js → chunk-E2ER4LJF.js} +304 -9
  20. package/dist/{chunk-7RXG6QRZ.js → chunk-EKY57CSP.js} +2 -75
  21. package/dist/{chunk-EDRHSCIE.js → chunk-EYPA3EGJ.js} +10 -2
  22. package/dist/{chunk-TTNYS3EA.js → chunk-G7MUEIGA.js} +1 -1
  23. package/dist/{chunk-BPGS2WCQ.js → chunk-GEYXPTRF.js} +2 -1
  24. package/dist/{chunk-BEY543CS.js → chunk-GOXNB3AO.js} +5 -2
  25. package/dist/{chunk-G4BMMOKF.js → chunk-HVDIIIQW.js} +2 -2
  26. package/dist/chunk-HWUFFB6L.js +83 -0
  27. package/dist/{chunk-35MKKU5R.js → chunk-K7T3E2SR.js} +15 -8
  28. package/dist/{chunk-VAWWTKDP.js → chunk-KHSFENX2.js} +2 -2
  29. package/dist/chunk-LCGCVYZ4.js +57 -0
  30. package/dist/{chunk-X6COSD2O.js → chunk-LYF7OHWH.js} +41 -14
  31. package/dist/{chunk-POHLU5DW.js → chunk-M6L6IDJG.js} +3 -3
  32. package/dist/{chunk-X4RCMKVQ.js → chunk-NDINPTJ4.js} +2 -2
  33. package/dist/{chunk-5M54SPOL.js → chunk-ODFEOB4F.js} +161 -5
  34. package/dist/{chunk-3JLKSKD7.js → chunk-OH3TOQTB.js} +5 -1
  35. package/dist/{chunk-MEQ45TQ4.js → chunk-PBTHKCPN.js} +18 -4
  36. package/dist/{chunk-ED4KHGC3.js → chunk-PPAMZ32Z.js} +9 -2
  37. package/dist/{chunk-QQL5RT5M.js → chunk-QM3F2GKX.js} +94 -36
  38. package/dist/{chunk-A2GZF7DC.js → chunk-QNQHSOLF.js} +4 -4
  39. package/dist/{chunk-KRPY7NTG.js → chunk-R46L2BIR.js} +3 -3
  40. package/dist/{chunk-BP4OYD6A.js → chunk-RY3LY4J5.js} +20 -2
  41. package/dist/{chunk-34475P3I.js → chunk-TSHXZTOQ.js} +5 -4
  42. package/dist/{chunk-VJWL6YS5.js → chunk-UUVG37B4.js} +2 -2
  43. package/dist/{chunk-2TZWSW76.js → chunk-WHGPSPT5.js} +2 -2
  44. package/dist/{chunk-TW3WDMVS.js → chunk-WHJYKASB.js} +2 -2
  45. package/dist/{chunk-YHZX5GEU.js → chunk-XAKHZX5N.js} +2 -2
  46. package/dist/{chunk-HXG4IURW.js → chunk-XE2VEJHX.js} +2 -2
  47. package/dist/{chunk-3HZ5RWN2.js → chunk-XF5N4U5A.js} +7 -6
  48. package/dist/{chunk-ZYKPLLNQ.js → chunk-XXQNGV4M.js} +590 -32
  49. package/dist/{chunk-4JUF2NNX.js → chunk-XYDYPRZI.js} +4 -4
  50. package/dist/{chunk-VMNQ6OZA.js → chunk-ZRGEBJ4T.js} +971 -794
  51. package/dist/{chunk-2LZI5CAG.js → chunk-ZXF4XRKW.js} +75 -33
  52. package/dist/{chunk-VSNATDE6.js → chunk-ZZMN5OM4.js} +2 -2
  53. package/dist/cli/index.js +31 -31
  54. package/dist/{clio-J5JIOIDS.js → clio-M2KGYUFZ.js} +2 -2
  55. package/dist/{code-nav-AXCXSBHX.js → code-nav-GQNL7XA6.js} +5 -5
  56. package/dist/codewiki/build-worker.js +4 -4
  57. package/dist/{components-KELWS457.js → components-5TTYYX6G.js} +3 -3
  58. package/dist/{config-OEBMIN2U.js → config-XUUYQIWO.js} +27 -25
  59. package/dist/{configure-PUQOSIXQ.js → configure-IHJ7YOMV.js} +7 -7
  60. package/dist/{context-URSXPBCK.js → context-74JLXAWD.js} +12 -12
  61. package/dist/{context-MGSE4Z2T.js → context-75MIWW3U.js} +24 -22
  62. package/dist/{context-EKDCKUUZ.js → context-ZQ7SIFJV.js} +8 -7
  63. package/dist/{context-clear-KDAJRNUK.js → context-clear-GYKWNUML.js} +24 -22
  64. package/dist/{context-index-BZ4UYMTC.js → context-index-SSR5ECNE.js} +3 -3
  65. package/dist/{context-working-set-SBKMPPI2.js → context-working-set-UX5KEP4J.js} +11 -10
  66. package/dist/{dispatch-runner-MSWN72NK.js → dispatch-runner-GIJBHNFL.js} +21 -20
  67. package/dist/{docs-2C2LTVT2.js → docs-6FZSCG5B.js} +3 -3
  68. package/dist/{doctor-7BSE27PJ.js → doctor-SVJ5BZCW.js} +4 -4
  69. package/dist/{eval-IZGDOO4H.js → eval-CG6LLBLD.js} +47 -232
  70. package/dist/{evidence-SR7WXB5B.js → evidence-ZYFIEN42.js} +19 -18
  71. package/dist/{evolve-K7VE2CBX.js → evolve-QGEXEMDW.js} +19 -18
  72. package/dist/{extensions-QVDOHDGJ.js → extensions-ADGNCJJD.js} +3 -3
  73. package/dist/{fleet-7XMJNQNF.js → fleet-S5R4ZOQY.js} +49 -30
  74. package/dist/{fleet-preflight-AQNAH644.js → fleet-preflight-BHSNPBMH.js} +2 -2
  75. package/dist/{init-JGNPAYXT.js → init-5DRU55YR.js} +31 -29
  76. package/dist/memory-7YKKR6UC.js +467 -0
  77. package/dist/{models-ZMMLFJNN.js → models-ZPOLRU2C.js} +10 -10
  78. package/dist/{monitor-2F3T5KHP.js → monitor-US5F5YGZ.js} +33 -18
  79. package/dist/{orchestrator-ORHT43JB.js → orchestrator-E2AL4T5N.js} +1092 -659
  80. package/dist/{paths-UXLN5YYZ.js → paths-E7KYAQWE.js} +3 -3
  81. package/dist/{reset-NXGTYNUO.js → reset-KZ652EK6.js} +3 -3
  82. package/dist/{run-RF4WJGMT.js → run-SRNBKDWD.js} +52 -40
  83. package/dist/{share-UT3W6E4M.js → share-CGZE33UP.js} +3 -3
  84. package/dist/{skills-PSACKC5Q.js → skills-S2X4DLY5.js} +4 -4
  85. package/dist/{skills-eval-WJSI55RZ.js → skills-eval-W2GGIC4R.js} +19 -18
  86. package/dist/{targets-PIIRAOYS.js → targets-54SWINWB.js} +14 -12
  87. package/dist/{terminal-lease-ULWXWNVY.js → terminal-lease-SAIF2OGY.js} +5 -4
  88. package/dist/{uninstall-FZCQCDKC.js → uninstall-BVLWXKBT.js} +3 -3
  89. package/dist/{upgrade-346TZ6AV.js → upgrade-JKAR27XC.js} +8 -8
  90. package/dist/{usage-6KKXR32N.js → usage-MSAWCLX4.js} +60 -27
  91. package/dist/{verifiers-4UUM6TEE.js → verifiers-NCBTHHN2.js} +60 -54
  92. package/dist/{wiki-generate-7STOCIFZ.js → wiki-generate-GUSOQ6ZP.js} +30 -28
  93. package/dist/worker/entry.js +69 -58
  94. package/dist/{workspace-G4ZWUIPR.js → workspace-ZJ6BFM3Q.js} +4 -4
  95. package/docs/README.md +3 -3
  96. package/docs/acp.md +1 -1
  97. package/docs/alcf-provider.md +1 -1
  98. package/docs/architecture.md +2 -2
  99. package/docs/artifact-placement.md +1 -2
  100. package/docs/artifact-versions.md +1 -1
  101. package/docs/built-in-agents.md +1 -1
  102. package/docs/capacity-and-scheduling.md +1 -1
  103. package/docs/commands-and-modes.md +9 -7
  104. package/docs/configuration-and-targets.md +12 -1
  105. package/docs/context-engine.md +4 -2
  106. package/docs/context-working-set.md +4 -4
  107. package/docs/development-pipeline.md +1 -1
  108. package/docs/documentation-coverage.md +3 -3
  109. package/docs/documentation-guide.md +2 -2
  110. package/docs/eval-runner.md +1 -1
  111. package/docs/evals-internal.md +4 -45
  112. package/docs/evidence-and-memory.md +67 -7
  113. package/docs/evolution.md +1 -1
  114. package/docs/exit-codes-and-output.md +1 -1
  115. package/docs/extensions-and-sharing.md +2 -2
  116. package/docs/fleet-dispatch.md +28 -2
  117. package/docs/installation-and-lifecycle.md +2 -2
  118. package/docs/middleware-and-components.md +19 -2
  119. package/docs/model-catalog.md +1 -1
  120. package/docs/observability.md +3 -3
  121. package/docs/proactive-memory.md +26 -16
  122. package/docs/prompt-envelope-and-tools.md +4 -2
  123. package/docs/provider-adapter-cookbook.md +1 -1
  124. package/docs/release-cut-checklist.md +38 -35
  125. package/docs/safety-model.md +29 -7
  126. package/docs/scientific-validation.md +3 -3
  127. package/docs/session-lifecycle.md +1 -1
  128. package/docs/skills-marketplace.md +1 -1
  129. package/docs/tool-usage.md +2 -2
  130. package/docs/trace-store.md +1 -1
  131. package/docs/troubleshooting.md +1 -1
  132. package/docs/tui-design.md +38 -4
  133. package/docs/worker-dispatch-mechanics.md +1 -1
  134. package/package.json +7 -4
  135. package/src/cli/agents.ts +2 -3
  136. package/src/cli/argv.ts +14 -1
  137. package/src/cli/fleet.ts +15 -0
  138. package/src/cli/index.ts +1 -1
  139. package/src/cli/memory.ts +272 -10
  140. package/src/cli/modes/json-stream.ts +2 -2
  141. package/src/cli/modes/print.ts +12 -1
  142. package/src/cli/run.ts +22 -2
  143. package/src/cli/targets.ts +12 -3
  144. package/src/cli/usage.ts +55 -7
  145. package/src/core/bus-events.ts +3 -0
  146. package/src/core/response-model-id.ts +134 -0
  147. package/src/core/toml.ts +62 -0
  148. package/src/core/workspace-files.ts +0 -1
  149. package/src/domains/agents/builtins/architect.md +1 -1
  150. package/src/domains/agents/catalog.ts +5 -4
  151. package/src/domains/agents/recipe.ts +54 -14
  152. package/src/domains/agents/result-contract.ts +7 -4
  153. package/src/domains/context/bootstrap.ts +36 -27
  154. package/src/domains/context/project-metadata.ts +19 -63
  155. package/src/domains/context/prompt-context.ts +8 -0
  156. package/src/domains/context/working-set/policies/index.ts +3 -4
  157. package/src/domains/dispatch/budget-envelope.ts +396 -0
  158. package/src/domains/dispatch/contract.ts +2 -0
  159. package/src/domains/dispatch/extension.ts +81 -27
  160. package/src/domains/dispatch/orphan-recovery.ts +1 -0
  161. package/src/domains/dispatch/receipt-integrity.ts +4 -0
  162. package/src/domains/dispatch/state.ts +1 -0
  163. package/src/domains/dispatch/types.ts +10 -3
  164. package/src/domains/dispatch/validation.ts +14 -0
  165. package/src/domains/dispatch/worker-spawn.ts +14 -3
  166. package/src/domains/eval/metrics/evidence.ts +0 -116
  167. package/src/domains/eval/metrics/invariants.ts +1 -1
  168. package/src/domains/eval/runners/clio-run.ts +1 -10
  169. package/src/domains/eval/runners/external-command.ts +2 -29
  170. package/src/domains/eval/schema/suite.ts +0 -7
  171. package/src/domains/eval/suites/run.ts +1 -7
  172. package/src/domains/memory/index.ts +22 -0
  173. package/src/domains/memory/operations.ts +58 -1
  174. package/src/domains/memory/promotion.ts +281 -0
  175. package/src/domains/memory/prompt-section.ts +25 -5
  176. package/src/domains/memory/proposal.ts +51 -7
  177. package/src/domains/memory/task-bank.ts +3 -2
  178. package/src/domains/memory/task-memory-handoff.ts +181 -24
  179. package/src/domains/memory/task-memory-policy.ts +3 -1
  180. package/src/domains/memory/types.ts +37 -0
  181. package/src/domains/memory/validate.ts +178 -0
  182. package/src/domains/middleware/memory-intervention.ts +35 -25
  183. package/src/domains/middleware/runtime.ts +6 -0
  184. package/src/domains/middleware/skills-reminder.ts +19 -4
  185. package/src/domains/middleware/stalled-turn.ts +43 -1
  186. package/src/domains/middleware/types.ts +10 -0
  187. package/src/domains/observability/contract.ts +6 -1
  188. package/src/domains/observability/cost.ts +20 -4
  189. package/src/domains/observability/extension.ts +2 -2
  190. package/src/domains/providers/index.ts +3 -0
  191. package/src/domains/providers/model-discovery.ts +9 -0
  192. package/src/domains/providers/runtime-resolution.ts +38 -1
  193. package/src/domains/providers/runtimes/common/probe-helpers.ts +97 -16
  194. package/src/domains/providers/types/context-window-slots.ts +18 -0
  195. package/src/domains/providers/types/runtime-descriptor.ts +3 -1
  196. package/src/domains/safety/call-target.ts +211 -14
  197. package/src/domains/safety/decision-presentation.ts +268 -0
  198. package/src/domains/safety/redaction.ts +73 -0
  199. package/src/domains/session/context-ledger.ts +10 -1
  200. package/src/domains/session/decision-board.ts +4 -0
  201. package/src/domains/session/entries.ts +3 -0
  202. package/src/domains/session/history.ts +68 -19
  203. package/src/domains/session/usage.ts +24 -7
  204. package/src/engine/acp/event-mapper.ts +7 -0
  205. package/src/engine/acp/server.ts +29 -2
  206. package/src/engine/apis/lmstudio.ts +25 -4
  207. package/src/engine/apis/openai-completions.ts +147 -22
  208. package/src/engine/claude/sdk-runtime.ts +8 -2
  209. package/src/engine/claude/tool-safety.ts +13 -0
  210. package/src/engine/loop-guard.ts +27 -3
  211. package/src/engine/worker-events.ts +4 -3
  212. package/src/engine/worker-runtime.ts +59 -54
  213. package/src/entry/orchestrator.ts +18 -1
  214. package/src/interactive/chat-loop-messages.ts +22 -0
  215. package/src/interactive/chat-loop.ts +13 -0
  216. package/src/interactive/chat-renderer.ts +19 -3
  217. package/src/interactive/clio-editor.ts +44 -7
  218. package/src/interactive/context-overlay.ts +43 -5
  219. package/src/interactive/cost-overlay.ts +39 -8
  220. package/src/interactive/dispatch-board.ts +212 -35
  221. package/src/interactive/footer/widgets.ts +13 -0
  222. package/src/interactive/interactive-application.ts +6 -1
  223. package/src/interactive/interactive-input-runtime.ts +11 -1
  224. package/src/interactive/interactive-presentation.ts +11 -1
  225. package/src/interactive/memory-overlay.ts +89 -4
  226. package/src/interactive/overlay-ask-user-lifecycle.ts +1 -1
  227. package/src/interactive/overlay-frame.ts +5 -2
  228. package/src/interactive/overlay-general-openers.ts +40 -1
  229. package/src/interactive/overlay-key-routing.ts +41 -1
  230. package/src/interactive/overlay-lifecycle.ts +11 -4
  231. package/src/interactive/overlay-permission-lifecycle.ts +23 -8
  232. package/src/interactive/overlay-transitions.ts +11 -0
  233. package/src/interactive/overlays/ask-user.ts +74 -30
  234. package/src/interactive/overlays/decisions.ts +3 -1
  235. package/src/interactive/permission-hint.ts +35 -0
  236. package/src/interactive/permission-overlay.ts +95 -45
  237. package/src/interactive/renderers/tool-execution.ts +19 -49
  238. package/src/interactive/session-last-turn.ts +8 -1
  239. package/src/interactive/session-usage-reseed.ts +36 -10
  240. package/src/interactive/slash-commands.ts +2 -2
  241. package/src/interactive/status/summary.ts +5 -0
  242. package/src/interactive/status/types.ts +5 -0
  243. package/src/interactive/terminal-lease.ts +1 -0
  244. package/src/interactive/turn-context.ts +96 -23
  245. package/src/interactive/turn-middleware.ts +1 -0
  246. package/src/interactive/turn-runtime.ts +37 -8
  247. package/src/interactive/turn-state.ts +3 -0
  248. package/src/interactive/worker-progress.ts +440 -0
  249. package/src/interactive/worker-stream.ts +51 -110
  250. package/src/tools/agent-tools.ts +28 -3
  251. package/src/tools/ask-user.ts +21 -1
  252. package/src/tools/context/index.ts +2 -2
  253. package/src/tools/dispatch-arguments.ts +8 -0
  254. package/src/tools/dispatch-event-text.ts +19 -0
  255. package/src/tools/dispatch.ts +24 -1
  256. package/src/tools/monitor.ts +15 -0
  257. package/src/tools/registry.ts +15 -5
  258. package/src/tools/result-disposition.ts +156 -0
  259. package/src/tools/result-shaping.ts +59 -1
  260. package/src/tools/verify/authoring.ts +55 -54
  261. package/src/tools/worker-evidence.ts +19 -0
  262. package/src/worker/spec-contract.ts +43 -3
  263. package/dist/chunk-EFADSJET.js +0 -18
  264. package/dist/memory-4ALKDJ4Q.js +0 -246
  265. package/src/domains/eval/metrics/chaos-stream.ts +0 -93
@@ -5,12 +5,18 @@ import { BusChannels, type LoopBlockedPayload } from "../../core/bus-events.js";
5
5
  import { DEFAULT_DELEGATION_PERMISSION_TIMEOUT_MS } from "../../core/defaults.js";
6
6
  import type { SafeEventBus } from "../../core/event-bus.js";
7
7
  import { MAX_TIMER_DELAY_MS } from "../../core/timers.js";
8
+ import { ToolNames } from "../../core/tool-names.js";
8
9
  import type { ProvidersContract } from "../../domains/providers/contract.js";
9
10
  import { isOrchestratorEligibleRuntime } from "../../domains/providers/eligibility.js";
10
11
  import { type AutonomyLevel, DEFAULT_AUTONOMY_LEVEL } from "../../domains/safety/autonomy.js";
12
+ import {
13
+ classifyDecisionPresentation,
14
+ decisionFactsForPermission,
15
+ } from "../../domains/safety/decision-presentation.js";
11
16
  import type { SessionContract, SessionMeta } from "../../domains/session/contract.js";
12
17
  import type { MessageEntry, SessionEntry } from "../../domains/session/entries.js";
13
18
  import { filterEntriesToActivePath } from "../../domains/session/tree/active-path.js";
19
+ import { askUserExposure } from "../../tools/ask-user.js";
14
20
  import type { ToolRegistry } from "../../tools/registry.js";
15
21
  import { toolResultPresentationText } from "../../tools/result-disposition.js";
16
22
  import type { AgentMessage } from "../types.js";
@@ -1265,6 +1271,19 @@ function installPermissionBridge(input: {
1265
1271
  const queuedRequestDetails = new Map<string, { tool: string; actionClass: string }>();
1266
1272
  const unregister = input.toolRegistry.onPermissionRequired((call, decision, meta) => {
1267
1273
  if (queuedRequestIds.has(meta.requestId)) return;
1274
+ const presentation = classifyDecisionPresentation(
1275
+ decisionFactsForPermission({
1276
+ tool: call.tool,
1277
+ actionClass: decision.classification.actionClass,
1278
+ axis: meta.axis.startsWith("net:")
1279
+ ? { kind: "safety-net", ruleId: meta.axis.slice("net:".length) || "unknown" }
1280
+ : { kind: "autonomy", level: meta.axis.slice("autonomy:".length) || DEFAULT_AUTONOMY_LEVEL },
1281
+ origin: { kind: "main" },
1282
+ ...(call.tool === ToolNames.AskUser ? { exposure: askUserExposure(call.args) } : {}),
1283
+ }),
1284
+ );
1285
+ const approveAction = presentation.requiredActions.find((action) => action.id === "approve-once");
1286
+ const denyAction = presentation.requiredActions.find((action) => action.id === "deny");
1268
1287
  queuedRequestIds.add(meta.requestId);
1269
1288
  queuedRequestDetails.set(meta.requestId, {
1270
1289
  tool: call.tool,
@@ -1381,8 +1400,16 @@ function installPermissionBridge(input: {
1381
1400
  ...(snapshot.locations !== undefined ? { locations: snapshot.locations } : {}),
1382
1401
  },
1383
1402
  options: [
1384
- { optionId: "allow-once", name: "Allow once", kind: "allow_once" },
1385
- { optionId: "reject-once", name: "Reject", kind: "reject_once" },
1403
+ {
1404
+ optionId: "allow-once",
1405
+ name: approveAction?.label ?? "Approve once",
1406
+ kind: "allow_once",
1407
+ },
1408
+ {
1409
+ optionId: "reject-once",
1410
+ name: denyAction?.label ?? "Deny this request",
1411
+ kind: "reject_once",
1412
+ },
1386
1413
  ],
1387
1414
  },
1388
1415
  input.permissionTimeoutMs,
@@ -130,6 +130,25 @@ async function loadOwnedInstance(
130
130
  return instanceId;
131
131
  }
132
132
 
133
+ /**
134
+ * Residency runs before every request, and the same co-residency facts held
135
+ * every turn, so the peer warning printed once per turn (issue #185). Each
136
+ * distinct fact is said once per process; a new target, model, or peer set is
137
+ * a new fact.
138
+ */
139
+ const announcedResidencyFacts = new Set<string>();
140
+
141
+ function emitResidencyNoticeOnce(key: string, notice: Parameters<typeof emitResidencyNotice>[0]): void {
142
+ if (announcedResidencyFacts.has(key)) return;
143
+ announcedResidencyFacts.add(key);
144
+ emitResidencyNotice(notice);
145
+ }
146
+
147
+ /** Test seam: forget which residency facts were announced. */
148
+ export function resetAnnouncedResidencyFacts(): void {
149
+ announcedResidencyFacts.clear();
150
+ }
151
+
133
152
  export async function ensureLmStudioResidency(
134
153
  model: Model<"openai-completions">,
135
154
  options: { apiKey?: string; signal?: AbortSignal } = {},
@@ -155,7 +174,7 @@ export async function ensureLmStudioResidency(
155
174
  );
156
175
  }
157
176
  if (resolution.wireModelId !== model.id) {
158
- emitResidencyNotice({
177
+ emitResidencyNoticeOnce(`resolved|${info.targetId}|${model.id}|${resolution.wireModelId}`, {
159
178
  kind: "co-resident",
160
179
  level: "info",
161
180
  targetId: info.targetId,
@@ -166,14 +185,16 @@ export async function ensureLmStudioResidency(
166
185
  });
167
186
  }
168
187
  if (resolution.peerTargets.length > 0) {
169
- emitResidencyNotice({
188
+ const peers = resolution.peerTargets.join(", ");
189
+ const requested = resolution.wireModelId === model.id ? "" : ` (requested '${model.id}')`;
190
+ emitResidencyNoticeOnce(`peer|${info.targetId}|${model.id}|${resolution.wireModelId}|${peers}`, {
170
191
  kind: "co-resident",
171
192
  level: "warning",
172
193
  targetId: info.targetId,
173
194
  runtimeId: "lmstudio",
174
195
  model: resolution.wireModelId,
175
- message: `LM Studio instance '${resolution.wireModelId}' is also loaded on ${resolution.peerTargets.join(", ")}; this request may be served by that LM Link peer.`,
176
- detail: { peerTargets: resolution.peerTargets.join(", ") },
196
+ message: `LM Studio instance '${resolution.wireModelId}'${requested} is also loaded on ${peers}; a request may be served by that LM Link peer, and the footer and usage ledger name the id that answered when it differs.`,
197
+ detail: { requestedModel: model.id, wireModel: resolution.wireModelId, peerTargets: peers },
177
198
  });
178
199
  }
179
200
  if (!load || Object.keys(load).length === 0 || resolution.instance) return resolution.wireModelId;
@@ -1,3 +1,4 @@
1
+ import { TextDecoder } from "node:util";
1
2
  import {
2
3
  type Api,
3
4
  type AssistantMessage,
@@ -15,6 +16,7 @@ import {
15
16
  type Usage,
16
17
  } from "@earendil-works/pi-ai";
17
18
  import { openAICompletionsApi } from "@earendil-works/pi-ai/api/openai-completions.lazy";
19
+ import type { ResponseModelIdObservation } from "../../core/response-model-id.js";
18
20
  import {
19
21
  type AppliedThinking,
20
22
  type ResolvedModelRuntimeCapabilities,
@@ -35,6 +37,13 @@ import { residencyManagedFor } from "./residency.js";
35
37
  import { mergeSamplingOverride } from "./sampling-overrides.js";
36
38
  import type { EngineApiProvider } from "./types.js";
37
39
 
40
+ declare module "@earendil-works/pi-ai" {
41
+ interface AssistantMessage {
42
+ /** Direct observation of model-id presence in an OpenAI-compatible response. */
43
+ responseModelIdObservation?: ResponseModelIdObservation;
44
+ }
45
+ }
46
+
38
47
  const piOpenAICompletions = openAICompletionsApi();
39
48
 
40
49
  /**
@@ -83,6 +92,124 @@ function isPlainRecord(value: unknown): value is Record<string, unknown> {
83
92
  return value !== null && typeof value === "object" && !Array.isArray(value);
84
93
  }
85
94
 
95
+ interface ResponseModelIdCapture {
96
+ reportedModelId: string | null;
97
+ observed: boolean;
98
+ done: boolean;
99
+ buffer: string;
100
+ decoder: TextDecoder | null;
101
+ }
102
+
103
+ function observeResponseModelIdLine(line: string, capture: ResponseModelIdCapture): void {
104
+ if (capture.done) return;
105
+ const normalized = line.endsWith("\r") ? line.slice(0, -1) : line;
106
+ if (!normalized.startsWith("data:")) return;
107
+ const data = normalized.slice("data:".length).trimStart();
108
+ if (data.length === 0 || data === "[DONE]") return;
109
+ try {
110
+ const payload = JSON.parse(data) as unknown;
111
+ if (!isPlainRecord(payload)) return;
112
+ const model = payload.model;
113
+ if (typeof model === "string" && model.trim().length > 0) {
114
+ capture.reportedModelId = model.trim();
115
+ capture.done = true;
116
+ }
117
+ } catch {
118
+ // A partial or vendor-specific event is pi-ai's parsing concern. This
119
+ // observer records only complete OpenAI-compatible JSON data lines.
120
+ }
121
+ }
122
+
123
+ function observeResponseModelIdBytes(
124
+ chunk: Uint8Array | undefined,
125
+ capture: ResponseModelIdCapture,
126
+ flush = false,
127
+ ): void {
128
+ if (capture.done || !capture.decoder) return;
129
+ capture.buffer += chunk ? capture.decoder.decode(chunk, { stream: !flush }) : capture.decoder.decode();
130
+ let newline = capture.buffer.indexOf("\n");
131
+ while (newline >= 0) {
132
+ const line = capture.buffer.slice(0, newline);
133
+ capture.buffer = capture.buffer.slice(newline + 1);
134
+ observeResponseModelIdLine(line, capture);
135
+ if (capture.done) {
136
+ capture.buffer = "";
137
+ capture.decoder = null;
138
+ return;
139
+ }
140
+ newline = capture.buffer.indexOf("\n");
141
+ }
142
+ if (flush && capture.buffer.length > 0) {
143
+ observeResponseModelIdLine(capture.buffer, capture);
144
+ capture.buffer = "";
145
+ }
146
+ if (capture.done || flush) capture.decoder = null;
147
+ }
148
+
149
+ function captureResponseModelId(response: Response, capture: ResponseModelIdCapture): Response {
150
+ if (!response.body || !response.headers.get("content-type")?.toLowerCase().includes("text/event-stream")) {
151
+ return response;
152
+ }
153
+ const body = response.body.pipeThrough(
154
+ new TransformStream<Uint8Array, Uint8Array>({
155
+ transform(chunk, controller) {
156
+ capture.observed = true;
157
+ if (!capture.done) observeResponseModelIdBytes(chunk, capture);
158
+ controller.enqueue(chunk);
159
+ },
160
+ flush() {
161
+ capture.observed = true;
162
+ if (!capture.done) observeResponseModelIdBytes(undefined, capture, true);
163
+ },
164
+ }),
165
+ );
166
+ return new Response(body, {
167
+ status: response.status,
168
+ statusText: response.statusText,
169
+ headers: response.headers,
170
+ });
171
+ }
172
+
173
+ function withResponseModelIdCapture<TOptions extends StreamOptions>(
174
+ options: TOptions,
175
+ sourceFactory: (capturedOptions: TOptions) => AssistantMessageEventStream,
176
+ ): AssistantMessageEventStream {
177
+ const capture: ResponseModelIdCapture = {
178
+ reportedModelId: null,
179
+ observed: false,
180
+ done: false,
181
+ buffer: "",
182
+ decoder: new TextDecoder(),
183
+ };
184
+ const fetchImpl: NonNullable<StreamOptions["fetch"]> =
185
+ options.fetch ?? ((input, init) => globalThis.fetch(input, init));
186
+ const capturedOptions = {
187
+ ...options,
188
+ fetch: async (input, init) => captureResponseModelId(await fetchImpl(input, init), capture),
189
+ } as TOptions;
190
+ const source = sourceFactory(capturedOptions);
191
+ const annotated = createAssistantMessageEventStream();
192
+ (async () => {
193
+ try {
194
+ for await (const event of source) {
195
+ const observation: ResponseModelIdObservation = capture.observed
196
+ ? capture.reportedModelId === null
197
+ ? { state: "not-reported" }
198
+ : { state: "reported", reportedModelId: capture.reportedModelId }
199
+ : { state: "not-observed" };
200
+ if (event.type === "done") event.message.responseModelIdObservation = observation;
201
+ else if (event.type === "error") event.error.responseModelIdObservation = observation;
202
+ annotated.push(event as AssistantMessageEvent);
203
+ }
204
+ annotated.end();
205
+ } catch (err) {
206
+ const message = err instanceof Error ? err.message : String(err);
207
+ throw new Error(message);
208
+ }
209
+ })();
210
+ return annotated;
211
+ }
212
+
86
213
  function samplingParamsFromProfile(profile: SamplingProfile): Record<string, unknown> {
87
214
  return {
88
215
  ...(profile.topP !== undefined ? { top_p: profile.topP } : {}),
@@ -735,17 +862,16 @@ export const openAICompletionsApiProvider: EngineApiProvider<"openai-completions
735
862
  stripSentinelsFromStream(
736
863
  stripNeverReasoningFromStream(
737
864
  filterGemmaChannelStream(
738
- withLocalResidency(
739
- model,
740
- {
741
- ...(options?.apiKey !== undefined ? { apiKey: options.apiKey } : {}),
742
- ...(options?.signal !== undefined ? { signal: options.signal } : {}),
743
- },
744
- (requestModel) =>
745
- piOpenAICompletions.stream(
746
- requestModel,
747
- effectiveContext,
748
- withRemainingContextBudget(model, effectiveContext, withSamplers),
865
+ withResponseModelIdCapture(
866
+ withRemainingContextBudget(model, effectiveContext, withSamplers),
867
+ (capturedOptions) =>
868
+ withLocalResidency(
869
+ model,
870
+ {
871
+ ...(options?.apiKey !== undefined ? { apiKey: options.apiKey } : {}),
872
+ ...(options?.signal !== undefined ? { signal: options.signal } : {}),
873
+ },
874
+ (requestModel) => piOpenAICompletions.stream(requestModel, effectiveContext, capturedOptions),
749
875
  ),
750
876
  ),
751
877
  resolved.family === "gemma-4",
@@ -768,17 +894,16 @@ export const openAICompletionsApiProvider: EngineApiProvider<"openai-completions
768
894
  stripSentinelsFromStream(
769
895
  stripNeverReasoningFromStream(
770
896
  filterGemmaChannelStream(
771
- withLocalResidency(
772
- model,
773
- {
774
- ...(options?.apiKey !== undefined ? { apiKey: options.apiKey } : {}),
775
- ...(options?.signal !== undefined ? { signal: options.signal } : {}),
776
- },
777
- (requestModel) =>
778
- piOpenAICompletions.streamSimple(
779
- requestModel,
780
- effectiveContext,
781
- withRemainingContextBudget(model, effectiveContext, withSamplers),
897
+ withResponseModelIdCapture(
898
+ withRemainingContextBudget(model, effectiveContext, withSamplers),
899
+ (capturedOptions) =>
900
+ withLocalResidency(
901
+ model,
902
+ {
903
+ ...(options?.apiKey !== undefined ? { apiKey: options.apiKey } : {}),
904
+ ...(options?.signal !== undefined ? { signal: options.signal } : {}),
905
+ },
906
+ (requestModel) => piOpenAICompletions.streamSimple(requestModel, effectiveContext, capturedOptions),
782
907
  ),
783
908
  ),
784
909
  resolved.family === "gemma-4",
@@ -413,9 +413,15 @@ function permissionResultForDecision(
413
413
  return result;
414
414
  }
415
415
 
416
- function decideToolUse(input: PermissionGateInput, toolName: string, toolInput: unknown): ClaudeToolPermissionDecision {
416
+ function decideToolUse(
417
+ input: PermissionGateInput,
418
+ toolCallId: string | undefined,
419
+ toolName: string,
420
+ toolInput: unknown,
421
+ ): ClaudeToolPermissionDecision {
417
422
  return emitClaudeToolPermissionDecision({
418
423
  toolName,
424
+ ...(toolCallId !== undefined ? { toolCallId } : {}),
419
425
  input: coerceToolInput(toolInput),
420
426
  safety: input.safety,
421
427
  cwd: input.cwd,
@@ -447,7 +453,7 @@ function decideToolUseOnce(
447
453
  toolInput: unknown,
448
454
  ): ClaudeToolPermissionDecision {
449
455
  return decideClaudeSdkToolUseOnce(input.handledToolDecisions, toolUseID, () =>
450
- decideToolUse(input, toolName, toolInput),
456
+ decideToolUse(input, toolUseID, toolName, toolInput),
451
457
  );
452
458
  }
453
459
 
@@ -8,6 +8,7 @@ import {
8
8
  DEFAULT_AUTONOMY_LEVEL,
9
9
  mapAutonomy,
10
10
  } from "../../domains/safety/autonomy.js";
11
+ import { describeCallAction } from "../../domains/safety/call-target.js";
11
12
  import type { SafetyContract, SafetyDecision } from "../../domains/safety/contract.js";
12
13
  import type { RejectionMessage } from "../../domains/safety/rejection-feedback.js";
13
14
  import type { ToolFinishEvent, ToolStartEvent } from "../../tools/agent-tools.js";
@@ -68,6 +69,8 @@ export interface EvaluateClaudeToolPermissionInput {
68
69
  export interface EmitClaudeToolPermissionInput extends EvaluateClaudeToolPermissionInput {
69
70
  emit(event: ClioWorkerEvent): void;
70
71
  onPermission?: "deny" | "fail";
72
+ /** SDK tool-use id shared by the start and finish telemetry events. */
73
+ toolCallId?: string;
71
74
  }
72
75
 
73
76
  function isRecord(value: unknown): value is Record<string, unknown> {
@@ -324,9 +327,11 @@ function emitToolFinish(
324
327
  outcome: ToolFinishEvent["outcome"],
325
328
  reason: string,
326
329
  reasonCode?: string,
330
+ toolCallId?: string,
327
331
  ): void {
328
332
  const event: ToolFinishEvent = {
329
333
  tool: mapped.clioToolName,
334
+ ...(toolCallId !== undefined ? { toolCallId } : {}),
330
335
  posture: "operating",
331
336
  durationMs: Math.round(performance.now() - startedAtClock),
332
337
  outcome,
@@ -350,10 +355,16 @@ export function emitClaudeToolPermissionDecision(input: EmitClaudeToolPermission
350
355
  const startedAt = Date.now();
351
356
  const startedAtClock = performance.now();
352
357
  const decision = evaluateClaudeToolPermission(input);
358
+ const toolCallId = input.toolCallId?.trim() ? input.toolCallId : undefined;
359
+ // Mapped args are this side's own translation of the subprocess call, so the
360
+ // descriptor is composed from them here rather than downstream of the seam.
361
+ const action = describeCallAction(decision.mapped.clioToolName, decision.mapped.args);
353
362
  const start: ToolStartEvent = {
354
363
  tool: decision.mapped.clioToolName,
364
+ ...(toolCallId !== undefined ? { toolCallId } : {}),
355
365
  posture: "operating",
356
366
  startedAt,
367
+ ...(action !== null ? { action } : {}),
357
368
  };
358
369
  input.emit({ type: "clio_tool_start", payload: start });
359
370
  if (decision.kind === "allow") {
@@ -365,6 +376,7 @@ export function emitClaudeToolPermissionDecision(input: EmitClaudeToolPermission
365
376
  "ok",
366
377
  decision.reason,
367
378
  decision.reasonCode,
379
+ toolCallId,
368
380
  );
369
381
  return decision;
370
382
  }
@@ -391,6 +403,7 @@ export function emitClaudeToolPermissionDecision(input: EmitClaudeToolPermission
391
403
  "blocked",
392
404
  decision.reason,
393
405
  decision.reasonCode,
406
+ toolCallId,
394
407
  );
395
408
  return decision;
396
409
  }
@@ -458,12 +458,14 @@ export interface CreateLoopGuardRegistrationOptions {
458
458
  export interface LoopGuardRegistration extends MiddlewareHookRegistration {
459
459
  /** Read-only attempt counter for tests and telemetry. */
460
460
  callCount(): number;
461
+ /** One-way worker phase growth admitted before launch for a result-contract revision. */
462
+ extendWorkerToolCallPhase(phase: { toolCalls: number; readReserve: number }): boolean;
461
463
  }
462
464
 
463
465
  export function createLoopGuardRegistration(options: CreateLoopGuardRegistrationOptions): LoopGuardRegistration {
464
466
  const budget = options.turnBlockBudget ?? INTERACTIVE_LOOP_BLOCK_BUDGET;
465
467
  const cap = options.toolCallCap;
466
- const softLimit =
468
+ let softLimit =
467
469
  options.toolCallSoftLimit !== undefined &&
468
470
  Number.isSafeInteger(options.toolCallSoftLimit) &&
469
471
  options.toolCallSoftLimit > 0 &&
@@ -472,8 +474,8 @@ export function createLoopGuardRegistration(options: CreateLoopGuardRegistration
472
474
  : undefined;
473
475
  const turnBudget = options.turnToolCallBudget;
474
476
  const synthesisLockout = options.turnSynthesisLockout === true;
475
- const softReadReserve = options.toolCallSoftReadReserve ?? 0;
476
- const softReadReserveThreshold =
477
+ let softReadReserve = options.toolCallSoftReadReserve ?? 0;
478
+ let softReadReserveThreshold =
477
479
  softLimit !== undefined && softReadReserve > 0 && softLimit > softReadReserve ? softLimit - softReadReserve : null;
478
480
  const deliveryTools = [...new Set(options.deliveryTools ?? [])];
479
481
  const reserveAdmits = (tool: string | undefined): boolean => isReserveAdmittedTool(tool, deliveryTools);
@@ -850,6 +852,28 @@ export function createLoopGuardRegistration(options: CreateLoopGuardRegistration
850
852
  "blocks repeated tool calls, enforces tool-call caps, and annotates substantial identical read results across distinct arguments",
851
853
  hooks: ["before_tool", "after_tool"],
852
854
  callCount: () => count,
855
+ extendWorkerToolCallPhase(phase): boolean {
856
+ if (
857
+ softLimit === undefined ||
858
+ !Number.isSafeInteger(phase.toolCalls) ||
859
+ !Number.isSafeInteger(phase.readReserve) ||
860
+ phase.toolCalls <= 0 ||
861
+ phase.readReserve < 0 ||
862
+ phase.readReserve >= phase.toolCalls ||
863
+ (cap !== undefined && phase.toolCalls > cap) ||
864
+ phase.toolCalls < softLimit ||
865
+ (phase.toolCalls === softLimit && phase.readReserve <= softReadReserve)
866
+ ) {
867
+ return false;
868
+ }
869
+ softLimit = phase.toolCalls;
870
+ softReadReserve = phase.readReserve;
871
+ softReadReserveThreshold = softReadReserve > 0 && softLimit > softReadReserve ? softLimit - softReadReserve : null;
872
+ softReadReserveEntered = false;
873
+ softLockout = null;
874
+ reserveDenials = { denials: 0 };
875
+ return true;
876
+ },
853
877
  evaluate(input): ReadonlyArray<MiddlewareEffect> {
854
878
  if (input.hook === "after_tool") {
855
879
  recordSuccessfulResult(input);
@@ -53,9 +53,10 @@ export interface ClioPermissionEscalatedEvent {
53
53
  tool: string;
54
54
  summary: string;
55
55
  /**
56
- * Sanitized one-line preview of the call's object (command, path, or
57
- * compact args), so the operator's approval overlay can show what the
58
- * call will touch. The args themselves never cross the stdout seam.
56
+ * Sanitized one-line preview of the call's allowlisted object fields, so
57
+ * the operator's approval overlay can show what the call will touch.
58
+ * Unlisted fields carry only type-and-size summaries, and the args
59
+ * themselves never cross the stdout seam.
59
60
  */
60
61
  target?: string;
61
62
  axis?: string;
@@ -443,6 +443,42 @@ export function startWorkerRun(input: WorkerRunInput, emit: WorkerEventEmit): Wo
443
443
  // loop guard counts synthesis-lock noncompliance by model round, preventing
444
444
  // one wide parallel batch from consuming the entire denial backstop.
445
445
  let workerModelRound = 0;
446
+ const loopGuardRegistration = createLoopGuardRegistration({
447
+ safety,
448
+ toolCallCap: workerBudget.hardCap,
449
+ toolCallSoftLimit: workerBudget.toolCalls,
450
+ // A worker's blocks all land in one run-long bucket, so the bound on
451
+ // them is a statement about this run's length, not about a turn.
452
+ turnBlockBudget: workerLoopBlockBudget(workerBudget.revision?.toolCalls ?? workerBudget.toolCalls),
453
+ toolCallSoftReadReserve: readReserve,
454
+ ...(deliveryTools.length > 0 ? { deliveryTools } : {}),
455
+ turnSynthesisLockout: workerBudget.synthesis,
456
+ // Once locked, the next model round is forced text-only at the
457
+ // request level. The lockout directive alone relies on model compliance.
458
+ onSynthesisLockout: () => {
459
+ if (workerBudget.synthesis) synthesisToolLock = true;
460
+ },
461
+ ...(!workerBudget.synthesis
462
+ ? {
463
+ onSoftLimitFinalCallAdmitted: (toolCallId: string | undefined) => {
464
+ if (workerBoundFailure === null) {
465
+ workerBoundFailure = `worker agent budget reached (${workerBudget.toolCalls}); synthesis is disabled`;
466
+ }
467
+ // Native agent tool calls carry a stable provider id. The wildcard
468
+ // keeps even an invariant violation on the post-result path.
469
+ stopAfterToolResultCallId = toolCallId ?? STOP_AFTER_ANY_TOOL_RESULT;
470
+ },
471
+ }
472
+ : {}),
473
+ // Requiring read is correct only when reading is the whole reserve.
474
+ ...(readReserve > 0 && deliveryTools.length === 0
475
+ ? {
476
+ onSoftReadReserve: () => {
477
+ middlewareToolChoice.apply([{ kind: "require_tool", toolName: ToolNames.Read }]);
478
+ },
479
+ }
480
+ : {}),
481
+ });
446
482
  const registry = createWorkerToolRegistry(
447
483
  input.middlewareSnapshot,
448
484
  safety,
@@ -461,52 +497,7 @@ export function startWorkerRun(input: WorkerRunInput, emit: WorkerEventEmit): Wo
461
497
  // and has no persistence sink. It can still absorb worker-local
462
498
  // protect_path effects from snapshot rules for the rest of this run.
463
499
  [
464
- createLoopGuardRegistration({
465
- safety,
466
- toolCallCap: workerBudget.hardCap,
467
- toolCallSoftLimit: workerBudget.toolCalls,
468
- // A worker's blocks all land in one run-long bucket, so the bound on
469
- // them is a statement about this run's length, not about a turn.
470
- turnBlockBudget: workerLoopBlockBudget(workerBudget.toolCalls),
471
- toolCallSoftReadReserve: readReserve,
472
- ...(deliveryTools.length > 0 ? { deliveryTools } : {}),
473
- turnSynthesisLockout: workerBudget.synthesis,
474
- // Once locked, the next model round is forced text-only at the
475
- // request level (the tool surface is removed in onPayload below):
476
- // the lockout directive alone relies on model compliance, and
477
- // measured local models kept calling tools until the backstop
478
- // aborted the run, or answered the forced round with tool-call
479
- // markup that the loop guard then removed.
480
- onSynthesisLockout: () => {
481
- if (workerBudget.synthesis) {
482
- synthesisToolLock = true;
483
- }
484
- },
485
- ...(!workerBudget.synthesis
486
- ? {
487
- onSoftLimitFinalCallAdmitted: (toolCallId: string | undefined) => {
488
- if (workerBoundFailure === null) {
489
- workerBoundFailure = `worker agent budget reached (${workerBudget.toolCalls}); synthesis is disabled`;
490
- }
491
- // Native agent tool calls carry a stable provider id. The wildcard
492
- // keeps even an invariant violation on the post-result path
493
- // instead of reintroducing a before_tool abort.
494
- stopAfterToolResultCallId = toolCallId ?? STOP_AFTER_ANY_TOOL_RESULT;
495
- },
496
- }
497
- : {}),
498
- // Forcing the next round to `read` is only correct when reading is
499
- // the whole reserve. An agent with delivery tools must be able to
500
- // write in its own reserve window, so it gets the steering directive
501
- // without the request-level lock.
502
- ...(readReserve > 0 && deliveryTools.length === 0
503
- ? {
504
- onSoftReadReserve: () => {
505
- middlewareToolChoice.apply([{ kind: "require_tool", toolName: ToolNames.Read }]);
506
- },
507
- }
508
- : {}),
509
- }),
500
+ loopGuardRegistration,
510
501
  createProtectedArtifactsRegistration({
511
502
  ...(input.protectedArtifactState !== undefined
512
503
  ? { initialState: { artifacts: [...input.protectedArtifactState.artifacts] } }
@@ -519,6 +510,7 @@ export function startWorkerRun(input: WorkerRunInput, emit: WorkerEventEmit): Wo
519
510
  );
520
511
  const contractCwd = input.cwd ?? process.cwd();
521
512
  let resultContractRepairsQueued = 0;
513
+ let resultContractRevisionActive = false;
522
514
  /** Read tool call id -> what was asked for, pending that call's result. */
523
515
  const pendingReadCitations = new Map<string, { path: string; offset: number | null; tail: boolean }>();
524
516
  const observedReadRanges = new Map<string, Array<readonly [number, number]>>();
@@ -702,10 +694,17 @@ export function startWorkerRun(input: WorkerRunInput, emit: WorkerEventEmit): Wo
702
694
  if (violation !== null) {
703
695
  if (resultContractRepairsQueued < RESULT_CONTRACT_REPAIR_LIMIT) {
704
696
  resultContractRepairsQueued += 1;
705
- // A repair round is terminal by construction, so enforce the
706
- // directive's own claim that tool use is over at the request
707
- // level instead of trusting the model to honor it.
708
- synthesisToolLock = true;
697
+ if (!resultContractRevisionActive && workerBudget.revision !== undefined) {
698
+ resultContractRevisionActive = loopGuardRegistration.extendWorkerToolCallPhase(workerBudget.revision);
699
+ if (resultContractRevisionActive) {
700
+ synthesisToolLock = false;
701
+ middlewareToolChoice.reset();
702
+ stopAfterToolResultCallId = null;
703
+ }
704
+ }
705
+ const revisionToolsAvailable = resultContractRevisionActive && !synthesisToolLock;
706
+ // A repair without preauthorized growth remains terminal and text-only.
707
+ if (!revisionToolsAvailable) synthesisToolLock = true;
709
708
  // Delivered as a paired tool exchange, never a user turn. Templates
710
709
  // that key history rendering off the last `user` message re-render
711
710
  // every earlier assistant turn when one is appended mid-run, which
@@ -714,7 +713,13 @@ export function startWorkerRun(input: WorkerRunInput, emit: WorkerEventEmit): Wo
714
713
  // bytes as a tool result (#55). The synthetic assistant call gives
715
714
  // the result a real tool_call_id for strict endpoints (#62).
716
715
  const repair = resultContractRepairMessages(
717
- { contract, reason: violation, attempt: resultContractRepairsQueued, anchors: observedReadAnchors() },
716
+ {
717
+ contract,
718
+ reason: violation,
719
+ attempt: resultContractRepairsQueued,
720
+ anchors: observedReadAnchors(),
721
+ ...(revisionToolsAvailable ? { toolsAvailable: true } : {}),
722
+ },
718
723
  { provider: model.provider, api: model.api, model: model.id },
719
724
  );
720
725
  for (const message of repair) agent.followUp(message as unknown as AgentMessage);
@@ -915,9 +920,9 @@ export function startWorkerRun(input: WorkerRunInput, emit: WorkerEventEmit): Wo
915
920
  const timer = setTimeout(() => resolveEscalation(requestId, "deny", "timeout"), escalationConfig.timeoutMs);
916
921
  activeEscalation = { requestId, tool: call.tool, actionClass, callKey, timer };
917
922
  // The operator decides on this exact call, so the escalation carries a
918
- // sanitized preview of its object; the args stay inside the worker. The
919
- // cap bounds the NDJSON line, and the overlay truncates further.
920
- const target = describeCallTarget(call.args).slice(0, 200);
923
+ // sanitized allowlisted preview of its object. Unlisted fields cross only
924
+ // as type-and-size summaries, and the args stay inside the worker.
925
+ const target = describeCallTarget(call.tool, call.args);
921
926
  emit({
922
927
  type: "clio_permission_escalated",
923
928
  payload: {