@iowarp/clio-coder 0.3.2 → 0.3.4

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 (259) hide show
  1. package/CHANGELOG.md +269 -458
  2. package/CONTRIBUTING.md +1 -1
  3. package/README.md +3 -3
  4. package/dist/{acp-BIYHVZIM.js → acp-S5R4RR5B.js} +7 -6
  5. package/dist/{agents-YT6SSRIT.js → agents-P6DMMVZY.js} +24 -21
  6. package/dist/assets/codewiki.json +1 -1
  7. package/dist/{auth-5TWEIYDN.js → auth-2XCZLPKS.js} +12 -8
  8. package/dist/{chunk-GGXXDWE4.js → chunk-22NAGB7X.js} +2 -2
  9. package/dist/{chunk-WMSVI4G2.js → chunk-2LZI5CAG.js} +133 -13
  10. package/dist/{chunk-OAO4GE4M.js → chunk-2TZWSW76.js} +2 -2
  11. package/dist/{chunk-OOJYHWRB.js → chunk-34475P3I.js} +2 -2
  12. package/dist/{chunk-WVO7V2QY.js → chunk-35MKKU5R.js} +4 -4
  13. package/dist/{chunk-LBNRH5WM.js → chunk-3HZ5RWN2.js} +5 -5
  14. package/dist/{chunk-AGYYIBLL.js → chunk-3JLKSKD7.js} +2 -2
  15. package/dist/{chunk-MBS4V7ZP.js → chunk-4JUF2NNX.js} +7 -7
  16. package/dist/{chunk-ZDOOVTXZ.js → chunk-4OC57DA6.js} +27 -4
  17. package/dist/chunk-5M54SPOL.js +926 -0
  18. package/dist/{chunk-STBPMHSX.js → chunk-7RXG6QRZ.js} +51 -11
  19. package/dist/{chunk-77VKQEHF.js → chunk-A2GZF7DC.js} +5 -5
  20. package/dist/{chunk-A3CYT5EX.js → chunk-AD2SYQYC.js} +55 -2
  21. package/dist/chunk-AOCYTWAV.js +449 -0
  22. package/dist/chunk-BEY543CS.js +258 -0
  23. package/dist/{chunk-6N5PTWMY.js → chunk-BP4OYD6A.js} +32 -13
  24. package/dist/chunk-BPGS2WCQ.js +612 -0
  25. package/dist/{chunk-J5HN4RYU.js → chunk-BRXQQJFP.js} +8 -8
  26. package/dist/chunk-CFGTUFWB.js +67 -0
  27. package/dist/{chunk-CBCAPZAA.js → chunk-E25LMLRW.js} +2 -2
  28. package/dist/{chunk-G2DE3C7R.js → chunk-EDRHSCIE.js} +4 -4
  29. package/dist/{chunk-4KLWL3UC.js → chunk-EFADSJET.js} +2 -2
  30. package/dist/{chunk-M6SHUN7Q.js → chunk-FO5ZOVUY.js} +2 -2
  31. package/dist/chunk-FYYLNIL5.js +313 -0
  32. package/dist/{chunk-EPVUXGXG.js → chunk-HV5X7OR2.js} +14 -12
  33. package/dist/{chunk-TZTZS7QK.js → chunk-HXG4IURW.js} +5 -3
  34. package/dist/{chunk-IGLFWIYI.js → chunk-K6WL7QZT.js} +3 -3
  35. package/dist/chunk-K7VKOLQQ.js +15 -0
  36. package/dist/{chunk-BMEMKKIT.js → chunk-KOHPCX4K.js} +2 -2
  37. package/dist/{chunk-V4RXGQ5Q.js → chunk-KRPY7NTG.js} +10 -7
  38. package/dist/chunk-LL4KHSZI.js +22 -0
  39. package/dist/{chunk-KJ5LWLOE.js → chunk-MEQ45TQ4.js} +15 -9
  40. package/dist/{chunk-5UUP6MWO.js → chunk-MV3K5QF2.js} +5 -436
  41. package/dist/{chunk-AO4RKG4M.js → chunk-N4CZJQRK.js} +5 -5
  42. package/dist/{chunk-ARBGF5F7.js → chunk-NILBFAPG.js} +14 -8
  43. package/dist/chunk-OZNBF4L3.js +23 -0
  44. package/dist/{verify-G6V4D2G7.js → chunk-PCZJO5TI.js} +127 -42
  45. package/dist/chunk-QQK64KLB.js +1360 -0
  46. package/dist/{chunk-6EJV5X2W.js → chunk-QQL5RT5M.js} +979 -1619
  47. package/dist/{chunk-LZSJBIVT.js → chunk-QWU7ZBO7.js} +70 -720
  48. package/dist/{chunk-2EHAIA3X.js → chunk-RD5U66HV.js} +3 -3
  49. package/dist/{chunk-OKGUZO2U.js → chunk-SPULKLCF.js} +4 -3
  50. package/dist/{chunk-OQ33BKR3.js → chunk-TTNYS3EA.js} +3 -60
  51. package/dist/chunk-TW3WDMVS.js +677 -0
  52. package/dist/chunk-TZSKNMZG.js +434 -0
  53. package/dist/{chunk-7MNJORFF.js → chunk-UL3WSD3F.js} +6 -1
  54. package/dist/{chunk-7EYHLWU7.js → chunk-UZHIZC5S.js} +7 -7
  55. package/dist/{chunk-QTYWRVRA.js → chunk-VAWWTKDP.js} +8 -8
  56. package/dist/{chunk-X75S7HFS.js → chunk-VEZEGCGW.js} +214 -20
  57. package/dist/{chunk-OHHN2SO4.js → chunk-VMNQ6OZA.js} +98 -202
  58. package/dist/chunk-VSNATDE6.js +122 -0
  59. package/dist/chunk-W6GROXXM.js +69 -0
  60. package/dist/chunk-WPQLXFOZ.js +375 -0
  61. package/dist/{chunk-ORBHGJC5.js → chunk-WR67VIZY.js} +3 -3
  62. package/dist/{chunk-3ZXDFGR5.js → chunk-X6COSD2O.js} +5 -5
  63. package/dist/chunk-ZGVHUX3M.js +66 -0
  64. package/dist/{chunk-MAW544W2.js → chunk-ZWMF7253.js} +4 -4
  65. package/dist/{chunk-MQSRRFWA.js → chunk-ZYKPLLNQ.js} +563 -546
  66. package/dist/cli/index.js +27 -23
  67. package/dist/{clio-4LY5K2AC.js → clio-J5JIOIDS.js} +7 -6
  68. package/dist/{code-nav-7AX6FYE6.js → code-nav-AXCXSBHX.js} +5 -3
  69. package/dist/{config-GTLUW2PR.js → config-OEBMIN2U.js} +37 -27
  70. package/dist/{configure-R6A64DHX.js → configure-PUQOSIXQ.js} +16 -13
  71. package/dist/{context-5VKGUVJJ.js → context-EKDCKUUZ.js} +82 -7
  72. package/dist/{context-RW5HC47S.js → context-MGSE4Z2T.js} +33 -23
  73. package/dist/{context-JFZEJ7W5.js → context-URSXPBCK.js} +17 -9
  74. package/dist/{context-clear-6ZHBAZZT.js → context-clear-KDAJRNUK.js} +33 -23
  75. package/dist/context-working-set-SBKMPPI2.js +1552 -0
  76. package/dist/{dispatch-runner-VKBRCWQC.js → dispatch-runner-MSWN72NK.js} +43 -29
  77. package/dist/{doctor-KI767GSN.js → doctor-7BSE27PJ.js} +10 -10
  78. package/dist/{eval-XSSNATB4.js → eval-IZGDOO4H.js} +9 -8
  79. package/dist/{evidence-UA6AWDQQ.js → evidence-SR7WXB5B.js} +51 -23
  80. package/dist/{evolve-QNTFGV6Z.js → evolve-K7VE2CBX.js} +30 -20
  81. package/dist/{fleet-Q7UOMUSG.js → fleet-7XMJNQNF.js} +48 -38
  82. package/dist/{fleet-preflight-DDN536IT.js → fleet-preflight-AQNAH644.js} +3 -3
  83. package/dist/{init-WBB65ZHQ.js → init-JGNPAYXT.js} +41 -31
  84. package/dist/{memory-MD3O64RI.js → memory-4ALKDJ4Q.js} +32 -22
  85. package/dist/{models-BZU34YWD.js → models-ZMMLFJNN.js} +22 -19
  86. package/dist/{monitor-MEQA5C3I.js → monitor-2F3T5KHP.js} +55 -43
  87. package/dist/{orchestrator-CGFKEP27.js → orchestrator-ORHT43JB.js} +2507 -1896
  88. package/dist/{reset-L2FQEE3E.js → reset-NXGTYNUO.js} +4 -3
  89. package/dist/{run-IV4Q6RLN.js → run-RF4WJGMT.js} +51 -41
  90. package/dist/{share-S5BZQC5I.js → share-UT3W6E4M.js} +5 -4
  91. package/dist/{skills-LQEKRDTN.js → skills-PSACKC5Q.js} +2 -2
  92. package/dist/{skills-eval-3DC4HEWS.js → skills-eval-WJSI55RZ.js} +34 -24
  93. package/dist/{targets-C4SSGQOB.js → targets-PIIRAOYS.js} +23 -20
  94. package/dist/{terminal-lease-IT5JW2NR.js → terminal-lease-ULWXWNVY.js} +5 -4
  95. package/dist/{upgrade-7TT7SQ3G.js → upgrade-346TZ6AV.js} +18 -17
  96. package/dist/{usage-GV4PKT3M.js → usage-6KKXR32N.js} +34 -24
  97. package/dist/verifiers-4UUM6TEE.js +1214 -0
  98. package/dist/verify-X5HDROLA.js +25 -0
  99. package/dist/{wiki-generate-DQF6Z66B.js → wiki-generate-7STOCIFZ.js} +42 -31
  100. package/dist/worker/entry.js +33 -24
  101. package/docs/README.md +8 -7
  102. package/docs/acp.md +1 -1
  103. package/docs/alcf-provider.md +1 -1
  104. package/docs/architecture.md +2 -2
  105. package/docs/artifact-versions.md +1 -1
  106. package/docs/built-in-agents.md +1 -1
  107. package/docs/capacity-and-scheduling.md +1 -1
  108. package/docs/commands-and-modes.md +53 -21
  109. package/docs/config-knobs-audit.md +1 -2
  110. package/docs/configuration-and-targets.md +15 -1
  111. package/docs/context-engine.md +64 -12
  112. package/docs/context-working-set.md +194 -0
  113. package/docs/development-pipeline.md +1 -1
  114. package/docs/documentation-coverage.md +5 -5
  115. package/docs/documentation-guide.md +6 -5
  116. package/docs/environment-variables.md +2 -1
  117. package/docs/eval-runner.md +1 -1
  118. package/docs/evals-internal.md +14 -1
  119. package/docs/evidence-and-memory.md +74 -2
  120. package/docs/evolution.md +1 -1
  121. package/docs/exit-codes-and-output.md +1 -1
  122. package/docs/extensions-and-sharing.md +2 -2
  123. package/docs/fleet-dispatch.md +22 -7
  124. package/docs/glossary.md +21 -1
  125. package/docs/installation-and-lifecycle.md +6 -6
  126. package/docs/middleware-and-components.md +1 -1
  127. package/docs/model-catalog.md +7 -9
  128. package/docs/observability.md +4 -4
  129. package/docs/performance-methodology.md +2 -2
  130. package/docs/proactive-memory.md +1 -1
  131. package/docs/prompt-envelope-and-tools.md +4 -4
  132. package/docs/provider-adapter-cookbook.md +1 -1
  133. package/docs/release-cut-checklist.md +35 -35
  134. package/docs/safety-model.md +23 -4
  135. package/docs/scientific-validation.md +21 -3
  136. package/docs/session-lifecycle.md +3 -3
  137. package/docs/skills-marketplace.md +1 -1
  138. package/docs/tool-usage.md +79 -12
  139. package/docs/trace-store.md +1 -1
  140. package/docs/troubleshooting.md +1 -1
  141. package/docs/tui-design.md +2 -2
  142. package/docs/worker-dispatch-mechanics.md +11 -1
  143. package/package.json +8 -11
  144. package/skills/meta/clio-test/SKILL.md +20 -17
  145. package/skills/meta/clio-test/evals.md +3 -3
  146. package/skills/meta/clio-test/references/harness.md +35 -6
  147. package/skills/meta/clio-test/references/test-map.md +20 -10
  148. package/skills/registry.yaml +2 -2
  149. package/skills/skill-marketplace.json +1 -1
  150. package/src/cli/context-working-set.ts +513 -0
  151. package/src/cli/context.ts +8 -0
  152. package/src/cli/evidence.ts +20 -2
  153. package/src/cli/index.ts +4 -0
  154. package/src/cli/verifiers.ts +325 -0
  155. package/src/core/bash-exec.ts +39 -14
  156. package/src/core/bus-events.ts +19 -4
  157. package/src/core/config.ts +54 -0
  158. package/src/core/defaults.ts +50 -3
  159. package/src/core/git-commit-attribution.ts +46 -21
  160. package/src/core/verification-scripts.ts +6 -0
  161. package/src/domains/agents/builtins/verifier.md +3 -0
  162. package/src/domains/config/classify.ts +1 -0
  163. package/src/domains/config/keybindings.ts +3 -3
  164. package/src/domains/context/working-set/contract.ts +161 -0
  165. package/src/domains/context/working-set/defaults.ts +28 -0
  166. package/src/domains/context/working-set/engine.ts +203 -0
  167. package/src/domains/context/working-set/fold.ts +62 -0
  168. package/src/domains/context/working-set/horizon.ts +38 -0
  169. package/src/domains/context/working-set/marker.ts +103 -0
  170. package/src/domains/context/working-set/path-index.ts +436 -0
  171. package/src/domains/context/working-set/payload.ts +152 -0
  172. package/src/domains/context/working-set/policies/age-horizon.ts +55 -0
  173. package/src/domains/context/working-set/policies/index.ts +21 -0
  174. package/src/domains/context/working-set/policies/structural.ts +160 -0
  175. package/src/domains/context/working-set/project.ts +132 -0
  176. package/src/domains/context/working-set/protect.ts +109 -0
  177. package/src/domains/context/working-set/recall.ts +177 -0
  178. package/src/domains/context/working-set/replay/controls.ts +112 -0
  179. package/src/domains/context/working-set/replay/load-clio.ts +199 -0
  180. package/src/domains/context/working-set/replay/metrics.ts +185 -0
  181. package/src/domains/context/working-set/replay/reference-graph.ts +79 -0
  182. package/src/domains/context/working-set/replay/report.ts +139 -0
  183. package/src/domains/context/working-set/replay/runner.ts +325 -0
  184. package/src/domains/context/working-set/replay/synthetic.ts +422 -0
  185. package/src/domains/context/working-set/replay/trace.ts +21 -0
  186. package/src/domains/context/working-set/visible.ts +54 -0
  187. package/src/domains/evidence/build.ts +112 -45
  188. package/src/domains/evidence/eval.ts +24 -7
  189. package/src/domains/evidence/index.ts +53 -0
  190. package/src/domains/evidence/ordering.ts +12 -0
  191. package/src/domains/evidence/run-trust.ts +221 -0
  192. package/src/domains/evidence/store.ts +46 -6
  193. package/src/domains/evidence/trust-status.ts +854 -0
  194. package/src/domains/evidence/types.ts +26 -0
  195. package/src/domains/middleware/memory-intervention.ts +3 -0
  196. package/src/domains/middleware/stalled-turn.ts +165 -4
  197. package/src/domains/safety/autonomy.ts +1 -1
  198. package/src/domains/safety/default-path-policy.ts +8 -0
  199. package/src/domains/safety/finish-contract.ts +4 -3
  200. package/src/domains/safety/policy-engine.ts +48 -6
  201. package/src/domains/session/compaction/compact.ts +23 -1
  202. package/src/domains/session/compaction/cut-point.ts +2 -0
  203. package/src/domains/session/compaction/tokens.ts +16 -1
  204. package/src/domains/session/context-ledger.ts +2 -0
  205. package/src/domains/session/entries.ts +107 -1
  206. package/src/domains/session/manager.ts +9 -2
  207. package/src/domains/session/migrations/index.ts +22 -3
  208. package/src/engine/acp/server.ts +3 -0
  209. package/src/engine/agent.ts +18 -1
  210. package/src/engine/session.ts +9 -3
  211. package/src/entry/orchestrator.ts +16 -4
  212. package/src/interactive/chat-loop-messages.ts +18 -6
  213. package/src/interactive/chat-panel.ts +571 -244
  214. package/src/interactive/chat-renderer.ts +79 -39
  215. package/src/interactive/context-meter.ts +10 -0
  216. package/src/interactive/context-overlay.ts +81 -6
  217. package/src/interactive/context-recall-command.ts +110 -0
  218. package/src/interactive/editor-submit.ts +26 -1
  219. package/src/interactive/footer/widgets.ts +22 -20
  220. package/src/interactive/footer-panel.ts +6 -1
  221. package/src/interactive/interactive-application.ts +2 -0
  222. package/src/interactive/interactive-event-projection.ts +12 -0
  223. package/src/interactive/interactive-slash-runtime.ts +49 -8
  224. package/src/interactive/model-session-replay.ts +21 -0
  225. package/src/interactive/overlay-general-openers.ts +6 -0
  226. package/src/interactive/overlay-session-lifecycle.ts +8 -4
  227. package/src/interactive/overlays/ask-user.ts +146 -24
  228. package/src/interactive/renderers/tool-execution.ts +167 -56
  229. package/src/interactive/session-transcript.ts +2 -2
  230. package/src/interactive/slash-commands.ts +29 -2
  231. package/src/interactive/status/index.ts +12 -1
  232. package/src/interactive/status/reasoning.ts +87 -0
  233. package/src/interactive/status/summary.ts +13 -2
  234. package/src/interactive/transcript-detail.ts +120 -0
  235. package/src/interactive/turn-context.ts +238 -88
  236. package/src/interactive/turn-middleware.ts +6 -6
  237. package/src/tools/agent-tools.ts +11 -4
  238. package/src/tools/bash.ts +144 -82
  239. package/src/tools/builtin-tool-catalog.ts +18 -6
  240. package/src/tools/context/index.ts +105 -3
  241. package/src/tools/context/surface.ts +3 -2
  242. package/src/tools/core-bootstrap.ts +21 -0
  243. package/src/tools/dispatch-runner.ts +9 -7
  244. package/src/tools/monitor.ts +28 -20
  245. package/src/tools/presentation.ts +107 -0
  246. package/src/tools/registry.ts +65 -7
  247. package/src/tools/result-disposition.ts +550 -0
  248. package/src/tools/result-shaping.ts +262 -19
  249. package/src/tools/safe-exec.ts +2 -0
  250. package/src/tools/verify/authoring.ts +1119 -0
  251. package/src/tools/verify/catalog.ts +346 -0
  252. package/src/tools/verify/index.ts +13 -3
  253. package/src/tools/verify/scripts.ts +135 -37
  254. package/src/tools/verify/surface.ts +9 -5
  255. package/src/tools/worker-evidence.ts +35 -12
  256. package/dist/chunk-MNA4JGU4.js +0 -255
  257. package/dist/chunk-SRF2PJNW.js +0 -184
  258. package/dist/chunk-T6YILFSB.js +0 -80
  259. package/dist/chunk-VAKQQHWR.js +0 -434
@@ -15,11 +15,15 @@
15
15
  import { sanitizeCallTargetText } from "../../domains/safety/call-target.js";
16
16
  import { formatSize } from "../../engine/truncate.js";
17
17
  import { visibleWidth, wrapTextWithAnsi } from "../../engine/tui.js";
18
+ import { classifyResourceRead, toolPresentationPolicy } from "../../tools/presentation.js";
19
+ import { toolResultPresentationPolicy, toolResultPresentationText } from "../../tools/result-disposition.js";
18
20
  import type { ApprovalRequestView } from "../permission-overlay.js";
19
21
  import { clioTheme, formatCompactMs, GLYPH } from "../theme/index.js";
20
22
  import { renderDiffLines } from "./diff.js";
21
23
  import { tryRenderJson, tryRenderXml } from "./structured.js";
22
24
 
25
+ export { classifyResourceRead };
26
+
23
27
  const theme = clioTheme();
24
28
  const dim = (text: string): string => theme.fg("dim", text);
25
29
  const red = (text: string): string => theme.fg("error", text);
@@ -40,6 +44,8 @@ const FULL_RESULT_PREVIEW_LIMIT = 60_000;
40
44
  const FULL_RESULT_ROW_LIMIT = 120;
41
45
  const STREAMING_RESULT_ROW_LIMIT = 20;
42
46
  const ARGS_BODY_LINE_LIMIT = 24;
47
+ /** Rows of mutation diff a folded edit/write row keeps visible before it defers to the body. */
48
+ const FOLDED_DIFF_ROW_LIMIT = 24;
43
49
  const STATUS_OK_GLYPH = GLYPH.ok;
44
50
  const STATUS_ERROR_GLYPH = GLYPH.error;
45
51
 
@@ -76,6 +82,13 @@ export interface ToolExecutionFinished {
76
82
  * model reading the same transcript, to guess at the rule.
77
83
  */
78
84
  blockReason?: string | undefined;
85
+ /**
86
+ * Working-set eviction reason, when the projection has replaced this
87
+ * result's body for the model. The transcript still renders the full body:
88
+ * the ledger is what the operator scrolls, the projection is only what the
89
+ * next request carries. The tag says the two now differ here.
90
+ */
91
+ evictedReason?: string | undefined;
79
92
  /** Structured exit status when the caller has one; text parsing is legacy fallback only. */
80
93
  exitCode?: number | string | null | undefined;
81
94
  /** Local `!!` bash output is visible to the operator but excluded from model context. */
@@ -286,24 +299,6 @@ function summarizeArgs(toolName: string, args: unknown): string {
286
299
  return truncate(jsonStringifySafe(safeArgs), ARG_PREVIEW_LIMIT);
287
300
  }
288
301
 
289
- /**
290
- * Compact resource-read classification. Reads of skill/handbook/agent
291
- * instruction files and docs pages collapse to one labeled line and never
292
- * auto-expand; their bodies are reference material, not task output.
293
- */
294
- export function classifyResourceRead(toolName: string, args: unknown): string | null {
295
- if (toolName !== "read") return null;
296
- const path = readStringField(args, "path");
297
- if (path === null) return null;
298
- const normalized = path.replace(/\\/g, "/");
299
- const base = normalized.split("/").pop() ?? "";
300
- if (base === "SKILL.md") return "skill";
301
- if (base === "CLIO-CODER.md") return "handbook";
302
- if (base === "AGENTS.md") return "agents";
303
- if (/(^|\/)docs\//.test(normalized)) return "docs";
304
- return null;
305
- }
306
-
307
302
  function detailsOf(result: unknown): Record<string, unknown> | null {
308
303
  if (!isPlainObject(result)) return null;
309
304
  return isPlainObject(result.details) ? result.details : null;
@@ -448,15 +443,61 @@ function ledgerTail(finished: ToolExecutionFinished): { facts: string; offload:
448
443
  const parts: string[] = [];
449
444
  const outcome = outcomeSummary(finished);
450
445
  if (outcome !== null) parts.push(outcome);
451
- const bytes = isNonExecutedOutcome(finished.outcome) ? null : shownBytesOf(finished);
452
- if (bytes !== null && bytes > 0) parts.push(formatSize(bytes));
453
- const offloadPath = offloadPathOf(finished);
446
+ const executed = !isNonExecutedOutcome(finished.outcome);
447
+ // A folded bash row is the only view of the call the operator gets by
448
+ // default, so its settlement facts have to be here rather than only in the
449
+ // expanded body. A failed row already carries `(exit N)` on the status glyph.
450
+ if (executed && finished.toolName === "bash" && !finished.isError) {
451
+ parts.push(`exit ${structuredExitCode(finished) ?? "0"}`);
452
+ }
453
+ const bytes = executed ? shownBytesOf(finished) : null;
454
+ if (bytes !== null && bytes > 0) {
455
+ const total = totalBytesOf(finished);
456
+ parts.push(
457
+ total !== null && total > bytes ? `${formatSize(bytes)} shown / ${formatSize(total)} total` : formatSize(bytes),
458
+ );
459
+ }
460
+ if (executed) {
461
+ if (isTruncatedResult(finished)) parts.push("truncated");
462
+ const details = detailsOf(finished.result);
463
+ if (details?.timedOut === true) parts.push("timed out");
464
+ if (details?.outputCapped === true) parts.push("output capped");
465
+ }
466
+ if (finished.excludeFromContext === true) parts.push("excluded from context");
467
+ if (finished.evictedReason !== undefined) parts.push("evicted", finished.evictedReason);
468
+ const offloadPath = executed ? offloadPathOf(finished) : null;
454
469
  return {
455
470
  facts: parts.length > 0 ? dim(` · ${parts.join(" · ")}`) : "",
456
471
  offload: offloadPath !== null ? dim(` · full: ${offloadPath}`) : "",
457
472
  };
458
473
  }
459
474
 
475
+ /** Longest diagnostic excerpt a folded failure row may carry. */
476
+ const FAILURE_EXCERPT_LIMIT = 80;
477
+
478
+ /**
479
+ * Last non-empty output line of a failed call, bounded so the folded row stays
480
+ * diagnostically useful without opening the body. Sanitized to one line: the
481
+ * source is raw tool output. The tool's presentation policy decides whether
482
+ * its failures carry one; the renderer never names a tool here.
483
+ */
484
+ function failureExcerpt(finished: ToolExecutionFinished, width: number): string {
485
+ const presentation =
486
+ toolResultPresentationPolicy(finished.result) ?? toolPresentationPolicy(finished.toolName, finished.args);
487
+ if (!finished.isError || !presentation.failureExcerpt) return "";
488
+ if (isNonExecutedOutcome(finished.outcome)) return "";
489
+ const text = unwrapResultEnvelope(finished.result);
490
+ if (typeof text !== "string") return "";
491
+ let excerpt: string | undefined;
492
+ for (const raw of text.split("\n")) {
493
+ const line = sanitizeCallTargetText(raw).trim();
494
+ if (line.length > 0) excerpt = line;
495
+ }
496
+ if (excerpt === undefined) return "";
497
+ const limit = Math.min(FAILURE_EXCERPT_LIMIT, Math.max(20, width - 20));
498
+ return dim(` · ${truncate(excerpt, limit)}`);
499
+ }
500
+
460
501
  /**
461
502
  * Marker for a call parked at admission awaiting operator approval. Rendered
462
503
  * in place of the elapsed counter so a parked call never reads as executing
@@ -531,8 +572,8 @@ function statusGlyph(status: HeaderStatus, meta: StatusMeta = {}): string {
531
572
  }
532
573
 
533
574
  function headerLine(toolName: string, args: unknown, status: HeaderStatus, meta: StatusMeta = {}): string {
534
- const summary = summarizeArgs(toolName, args);
535
- const head = `${dim(HEADER_PREFIX_PLAIN)}${cyanBold(toolName)}${dim("(")}${cyan(summary)}${dim(")")}`;
575
+ const body = styleSublineBody(buildSublineBody(toolName, args, status, undefined, meta.outcome));
576
+ const head = `${dim(HEADER_PREFIX_PLAIN)}${body}`;
536
577
  return `${head}${statusGlyph(status, meta)}`;
537
578
  }
538
579
 
@@ -546,8 +587,9 @@ function styleSublineBody(body: string): string {
546
587
  return sublineLead(match.groups.lead, match.groups.rest);
547
588
  }
548
589
 
549
- function buildUnknownToolBody(toolName: string, args: unknown): string {
550
- return `${toolName}(${summarizeArgs(toolName, args)})`;
590
+ function buildGenericToolBody(toolName: string, args: unknown): string {
591
+ const summary = summarizeArgs(toolName, args);
592
+ return summary.length > 0 ? `tool action ${summary}` : "tool action";
551
593
  }
552
594
 
553
595
  function buildFieldSublineBody(
@@ -604,6 +646,7 @@ const SUBLINE_BODY_BUILDERS: Readonly<Record<string, (args: unknown) => string |
604
646
  bash: (args) => buildFieldSublineBody(args, "command", "running ", { wrapInBackticks: true }),
605
647
  grep: (args) => buildFieldSublineBody(args, "pattern", "searching for ", { wrapInBackticks: true }),
606
648
  find: (args) => buildFieldSublineBody(args, "pattern", "finding ", { wrapInBackticks: true }),
649
+ web_fetch: (args) => buildFieldSublineBody(args, "url", "fetching "),
607
650
  git: (args) => buildFieldSublineBody(args, "op", "git "),
608
651
  verify: (args) => buildFieldSublineBody(args, "check", "verifying "),
609
652
  code_nav: (args) => {
@@ -641,7 +684,7 @@ const SUBLINE_BODY_BUILDERS: Readonly<Record<string, (args: unknown) => string |
641
684
  /**
642
685
  * Per-tool subline templates. Maps a tool name to a function that builds the
643
686
  * verb-led subline body without the leading glyph and without the trailing
644
- * status glyph. Unknown tools fall back to the existing `<name>(<arg>)` form.
687
+ * status glyph. Unknown tools fall back to a tool-neutral action summary.
645
688
  */
646
689
  function webFetchMeta(result: unknown): string | null {
647
690
  if (!isPlainObject(result) || !isPlainObject(result.details)) return null;
@@ -658,11 +701,10 @@ function webFetchMeta(result: unknown): string | null {
658
701
 
659
702
  /**
660
703
  * A call the permission gate blocked never executed, so the collapsed row must
661
- * not claim it did. `ran \`id\` · 667B blocked` read as a command that ran and
662
- * produced 667 bytes, while the expanded header for the same node correctly said
663
- * `bash(id) blocked`. Blocked rows use that same call-signature form, and the
664
- * ledger byte count is suppressed because those bytes are the denial text, not
665
- * output.
704
+ * not claim it did. Blocked calls keep the ordinary operator-facing action
705
+ * description but use a neutral noun for commands; the status tail supplies the
706
+ * blocked outcome. The ledger byte count is suppressed elsewhere because those
707
+ * bytes are the denial text, not output.
666
708
  */
667
709
  function isNonExecutedOutcome(outcome: ToolExecutionFinished["outcome"]): boolean {
668
710
  return outcome === "blocked";
@@ -675,21 +717,29 @@ function buildSublineBody(
675
717
  result?: unknown,
676
718
  outcome?: ToolExecutionFinished["outcome"],
677
719
  ): string {
678
- if (isNonExecutedOutcome(outcome)) return buildUnknownToolBody(toolName, args);
679
- if (status === "forming" || status === "ready") return buildUnknownToolBody(toolName, args);
720
+ if (isNonExecutedOutcome(outcome)) {
721
+ if (toolName === "bash") {
722
+ return (
723
+ buildFieldSublineBody(args, "command", "command ", { wrapInBackticks: true }) ??
724
+ buildGenericToolBody(toolName, args)
725
+ );
726
+ }
727
+ return SUBLINE_BODY_BUILDERS[toolName]?.(args) ?? buildGenericToolBody(toolName, args);
728
+ }
680
729
  if (toolName === "web_fetch") {
681
730
  const meta = status === undefined ? null : webFetchMeta(result);
682
- return `${buildUnknownToolBody(toolName, args)}${meta ? dim(` · ${meta}`) : ""}`;
731
+ const body = SUBLINE_BODY_BUILDERS.web_fetch?.(args) ?? buildGenericToolBody(toolName, args);
732
+ return `${body}${meta ? dim(` · ${meta}`) : ""}`;
683
733
  }
684
734
  if (toolName === "bash") {
685
- const lead = status === "running" || status === undefined ? "running " : "ran ";
735
+ const lead = status === "ok" || status === "error" ? "ran " : "running ";
686
736
  return (
687
- buildFieldSublineBody(args, "command", lead, { wrapInBackticks: true }) ?? buildUnknownToolBody(toolName, args)
737
+ buildFieldSublineBody(args, "command", lead, { wrapInBackticks: true }) ?? buildGenericToolBody(toolName, args)
688
738
  );
689
739
  }
690
740
  const body = SUBLINE_BODY_BUILDERS[toolName]?.(args);
691
741
  if (body !== null && body !== undefined) return body;
692
- return buildUnknownToolBody(toolName, args);
742
+ return buildGenericToolBody(toolName, args);
693
743
  }
694
744
 
695
745
  interface SublineParts {
@@ -832,6 +882,8 @@ function renderArgsBody(toolName: string, args: unknown, width: number, isError:
832
882
  */
833
883
  function unwrapResultEnvelope(result: unknown): unknown {
834
884
  if (typeof result === "string" || result === null || result === undefined) return result;
885
+ const presentationText = toolResultPresentationText(result);
886
+ if (presentationText !== null) return presentationText;
835
887
  const blocks = Array.isArray(result)
836
888
  ? result
837
889
  : isPlainObject(result) && Array.isArray(result.content)
@@ -1002,6 +1054,7 @@ function outputFacts(finished: ToolExecutionFinished): string[] {
1002
1054
  }
1003
1055
  if (isPlainObject(finished.result) && finished.result.terminate === true) parts.push("terminal result");
1004
1056
  if (finished.excludeFromContext === true) parts.push("excluded from context");
1057
+ if (finished.evictedReason !== undefined) parts.push("evicted", finished.evictedReason);
1005
1058
  return parts;
1006
1059
  }
1007
1060
 
@@ -1116,10 +1169,42 @@ function sublineStatus(call: ToolExecutionStart | ToolExecutionFinished): Header
1116
1169
  * imports the keybindings manager directly; the caller resolves the key string
1117
1170
  * and passes it in to keep this module pure.
1118
1171
  */
1172
+ export interface ToolSublineRenderOptions {
1173
+ /** Live rows color the folded mutation diff; replay and export request plain rows. */
1174
+ diffStyle?: "color" | "plain";
1175
+ /**
1176
+ * Whether the folded row carries the extras its tool's presentation asks
1177
+ * for (today the bounded mutation diff). The bare subline level drops them;
1178
+ * the failure excerpt is not one of them and always rides the row.
1179
+ */
1180
+ foldedExtras?: "per-tool" | "none";
1181
+ }
1182
+
1183
+ /**
1184
+ * The bounded diff a folded mutation row keeps under itself, when the tool's
1185
+ * presentation asks for one and the result carries one. A folded `edit` that
1186
+ * hid its change told the operator nothing they could act on.
1187
+ */
1188
+ function foldedDiffRows(finished: ToolExecutionFinished, width: number, opts: ToolSublineRenderOptions): string[] {
1189
+ if (opts.foldedExtras === "none") return [];
1190
+ if (finished.isError || isNonExecutedOutcome(finished.outcome)) return [];
1191
+ const presentation =
1192
+ toolResultPresentationPolicy(finished.result) ?? toolPresentationPolicy(finished.toolName, finished.args);
1193
+ if (!presentation.showDiffWhenFolded) return [];
1194
+ const diff = resultDiff(finished.result);
1195
+ if (diff === null) return [];
1196
+ return truncateRowsMiddle(
1197
+ renderMutationDiffBlock(diff, width, opts.diffStyle !== "plain"),
1198
+ FOLDED_DIFF_ROW_LIMIT,
1199
+ false,
1200
+ );
1201
+ }
1202
+
1119
1203
  export function renderToolSubline(
1120
1204
  call: ToolExecutionStart | ToolExecutionFinished,
1121
1205
  width: number,
1122
1206
  expandKey?: string,
1207
+ opts: ToolSublineRenderOptions = {},
1123
1208
  ): string[] {
1124
1209
  const status = sublineStatus(call);
1125
1210
  const meta: StatusMeta =
@@ -1132,9 +1217,12 @@ export function renderToolSubline(
1132
1217
  }
1133
1218
  : { elapsedMs: call.elapsedMs };
1134
1219
  const parts = sublineParts(call, status, meta);
1220
+ const excerpt = "result" in call ? failureExcerpt(call, width) : "";
1135
1221
  const showHint = "result" in call && expandKey !== undefined && expandKey.length > 0;
1136
1222
  const tail = showHint ? `${parts.tail}${dim(` (${expandKey})`)}` : parts.tail;
1137
- return wrapSublineWithTail(parts.lead, tail, width);
1223
+ const lines = wrapSublineWithTail(`${parts.lead}${excerpt}`, tail, width);
1224
+ if ("result" in call) lines.push(...foldedDiffRows(call, width, opts));
1225
+ return lines;
1138
1226
  }
1139
1227
 
1140
1228
  /**
@@ -1294,6 +1382,13 @@ export interface BashTranscriptExecution {
1294
1382
  fullOutputPath?: string | undefined;
1295
1383
  excludeFromContext?: boolean | undefined;
1296
1384
  error?: string | undefined;
1385
+ /**
1386
+ * Whether the block draws its one-line row instead of the full body. The
1387
+ * caller resolves this from the transcript detail policy and the operator's
1388
+ * override, the same way the panel resolves a model bash call. Omitted means
1389
+ * folded.
1390
+ */
1391
+ folded?: boolean | undefined;
1297
1392
  }
1298
1393
 
1299
1394
  /**
@@ -1301,7 +1396,12 @@ export interface BashTranscriptExecution {
1301
1396
  * as model-initiated bash. This is a view projection only; callers keep Clio's
1302
1397
  * existing immutable `bashExecution` ledger entry.
1303
1398
  */
1304
- export function renderBashTranscriptExecution(execution: BashTranscriptExecution, width: number): string[] {
1399
+ export function renderBashTranscriptExecution(
1400
+ execution: BashTranscriptExecution,
1401
+ width: number,
1402
+ expandKey?: string,
1403
+ bodyOptions: ToolBodyRenderOptions = {},
1404
+ ): string[] {
1305
1405
  const shownBytes = Buffer.byteLength(execution.output, "utf8");
1306
1406
  const totalBytes = execution.totalBytes ?? shownBytes;
1307
1407
  const args: Record<string, unknown> = { command: execution.command };
@@ -1319,7 +1419,20 @@ export function renderBashTranscriptExecution(execution: BashTranscriptExecution
1319
1419
  content: [{ type: "text", text: execution.output }],
1320
1420
  details,
1321
1421
  };
1422
+ const folded = execution.folded !== false;
1322
1423
  if (execution.running) {
1424
+ if (folded) {
1425
+ return renderToolSubline(
1426
+ {
1427
+ toolCallId: "local-bash",
1428
+ toolName: "bash",
1429
+ args,
1430
+ elapsedMs: execution.elapsedMs,
1431
+ phase: "running",
1432
+ },
1433
+ width,
1434
+ );
1435
+ }
1323
1436
  return renderToolStreamingExecution(
1324
1437
  {
1325
1438
  toolCallId: "local-bash",
@@ -1336,22 +1449,20 @@ export function renderBashTranscriptExecution(execution: BashTranscriptExecution
1336
1449
  const output = message
1337
1450
  ? `${execution.output}${execution.output.length > 0 ? "\n\n" : ""}${message}`
1338
1451
  : execution.output;
1339
- return renderToolExecution(
1340
- {
1341
- toolCallId: "local-bash",
1342
- toolName: "bash",
1343
- args,
1344
- result: { ...result, content: [{ type: "text", text: output }] },
1345
- isError: execution.cancelled === true || execution.error !== undefined || (execution.exitCode ?? 0) !== 0,
1346
- exitCode: execution.exitCode,
1347
- ...(execution.cancelled === true ? { outcome: "aborted" as const } : {}),
1348
- excludeFromContext: execution.excludeFromContext,
1349
- resultSummary: {
1350
- bytes: shownBytes,
1351
- truncated: execution.truncated === true,
1352
- ...(execution.fullOutputPath !== undefined ? { offloadPath: execution.fullOutputPath } : {}),
1353
- },
1452
+ const finished: ToolExecutionFinished = {
1453
+ toolCallId: "local-bash",
1454
+ toolName: "bash",
1455
+ args,
1456
+ result: { ...result, content: [{ type: "text", text: output }] },
1457
+ isError: execution.cancelled === true || execution.error !== undefined || (execution.exitCode ?? 0) !== 0,
1458
+ exitCode: execution.exitCode,
1459
+ ...(execution.cancelled === true ? { outcome: "aborted" as const } : {}),
1460
+ excludeFromContext: execution.excludeFromContext,
1461
+ resultSummary: {
1462
+ bytes: shownBytes,
1463
+ truncated: execution.truncated === true,
1464
+ ...(execution.fullOutputPath !== undefined ? { offloadPath: execution.fullOutputPath } : {}),
1354
1465
  },
1355
- width,
1356
- );
1466
+ };
1467
+ return folded ? renderToolSubline(finished, width, expandKey) : renderToolExecution(finished, width, bodyOptions);
1357
1468
  }
@@ -3,7 +3,7 @@ import { collectSessionEntries } from "../domains/session/compaction/session-ent
3
3
  import type { SessionContract, SessionEntry } from "../domains/session/index.js";
4
4
  import { openSession, sessionPaths } from "../engine/session.js";
5
5
  import type { ChatLoop } from "./chat-loop.js";
6
- import { buildReplayAgentMessagesFromTurns } from "./chat-renderer.js";
6
+ import { buildModelReplayAgentMessagesFromTurns } from "./model-session-replay.js";
7
7
 
8
8
  type SessionOwner = Pick<SessionContract, "create" | "current">;
9
9
  type SessionChat = Pick<ChatLoop, "resetForSession">;
@@ -87,7 +87,7 @@ export function createSessionTranscript(deps: SessionTranscriptDeps): SessionTra
87
87
  const turns = deps.readSessionEntries();
88
88
  deps.chat.resetForSession(
89
89
  leafTurnId,
90
- buildReplayAgentMessagesFromTurns(turns, {
90
+ buildModelReplayAgentMessagesFromTurns(turns, {
91
91
  ...(leafTurnId ? { activeLeafTurnId: leafTurnId } : {}),
92
92
  }),
93
93
  );
@@ -44,6 +44,8 @@ type SlashCommandVariant =
44
44
  | { kind: "init"; options: InitCommandOptions }
45
45
  | { kind: "context-clear"; options: ContextClearCommandOptions }
46
46
  | { kind: "context-refresh" }
47
+ /** `ref` is the turnId an `[evicted ...]` marker names. */
48
+ | { kind: "context-recall"; ref: string }
47
49
  | { kind: "skill-selector" }
48
50
  | { kind: "skill-invocation"; text: string }
49
51
  | { kind: "prompts" }
@@ -343,6 +345,13 @@ export interface SlashCommandContext {
343
345
  * Optional until the host wires onContextRefresh.
344
346
  */
345
347
  runContextRefresh?: () => void;
348
+ /**
349
+ * Read an evicted tool-result body back into the transcript by ref, and
350
+ * record the `contextRecall` entry. Transcript-only: an operator recall
351
+ * answers the person, so the body never becomes model context. Optional
352
+ * until the host wires a session.
353
+ */
354
+ runContextRecall?: (ref: string) => void;
346
355
  openSkillsHub?: () => void;
347
356
  listPrompts: () => ResourceList<PromptTemplate>;
348
357
  /**
@@ -481,6 +490,13 @@ const COMPACT_POSITIONALS: ReadonlyArray<CommandPositionalSpec> = [
481
490
  { name: "instructions", required: false, rest: true },
482
491
  ];
483
492
 
493
+ /**
494
+ * `/context recall <ref>` takes exactly one ref, not a rest tail: a ref is a
495
+ * single turnId, and a second token is a mistake worth reporting rather than
496
+ * text to fold into the first.
497
+ */
498
+ const RECALL_POSITIONALS: ReadonlyArray<CommandPositionalSpec> = [{ name: "ref", required: true }];
499
+
484
500
  /**
485
501
  * A registered command whose arguments do not parse is a mistake to report,
486
502
  * not a chat message to send. Falling through to `unknown` submitted the raw
@@ -859,11 +875,12 @@ export const BUILTIN_SLASH_COMMANDS: ReadonlyArray<BuiltinSlashCommand> = [
859
875
  },
860
876
  {
861
877
  name: "context",
862
- description: "Context hub: window overlay plus compact, init, refresh, and reset",
878
+ description: "Context hub: window overlay plus compact, recall, init, refresh, and reset",
863
879
  group: "Inspect",
864
- kinds: ["context-view", "compact", "init", "context-clear", "context-refresh"],
880
+ kinds: ["context-view", "compact", "context-recall", "init", "context-clear", "context-refresh"],
865
881
  subcommandDescriptions: {
866
882
  compact: "Compact session context",
883
+ recall: "Recall an evicted result",
867
884
  init: "Initialize project context",
868
885
  refresh: "Refresh project context",
869
886
  reset: "Reset project context",
@@ -871,6 +888,7 @@ export const BUILTIN_SLASH_COMMANDS: ReadonlyArray<BuiltinSlashCommand> = [
871
888
  args: {
872
889
  subcommands: {
873
890
  compact: { positionals: [...COMPACT_POSITIONALS] },
891
+ recall: { positionals: [...RECALL_POSITIONALS] },
874
892
  init: {},
875
893
  refresh: {},
876
894
  reset: {},
@@ -881,6 +899,8 @@ export const BUILTIN_SLASH_COMMANDS: ReadonlyArray<BuiltinSlashCommand> = [
881
899
  switch (parsed.subcommand) {
882
900
  case "compact":
883
901
  return { kind: "compact", instructions: parsed.rest };
902
+ case "recall":
903
+ return { kind: "context-recall", ref: parsed.positionals[0] ?? "" };
884
904
  case "init":
885
905
  return { kind: "init", options: {} };
886
906
  case "refresh":
@@ -899,6 +919,13 @@ export const BUILTIN_SLASH_COMMANDS: ReadonlyArray<BuiltinSlashCommand> = [
899
919
  case "compact":
900
920
  ctx.runCompact(command.instructions);
901
921
  return;
922
+ case "context-recall":
923
+ if (ctx.runContextRecall) {
924
+ ctx.runContextRecall(command.ref);
925
+ } else {
926
+ ctx.notice("error", "context recall is not wired; no session is bound to this process");
927
+ }
928
+ return;
902
929
  case "init":
903
930
  ctx.runInit(command.options);
904
931
  return;
@@ -1,13 +1,24 @@
1
1
  export type { StatusController, StatusControllerDeps } from "./controller.js";
2
2
  export { createStatusController } from "./controller.js";
3
+ export {
4
+ compactReasoningTokens,
5
+ formatReasoningChip,
6
+ formatReasoningLabel,
7
+ type ReasoningProvenance,
8
+ type ReasoningUsageView,
9
+ reasoningFromSummary,
10
+ reasoningFromTally,
11
+ UNMEASURED_REASONING,
12
+ } from "./reasoning.js";
3
13
  export { type ReduceContext, reduceStatus, type StatusInputEvent } from "./state-machine.js";
4
- export { type BuildSummaryInput, buildSummary } from "./summary.js";
14
+ export { type BuildSummaryInput, buildSummary, emptyRunTally, foldMessageIntoRunTally } from "./summary.js";
5
15
  export {
6
16
  type AgentStatus,
7
17
  type AgentStatusChangedPayload,
8
18
  type AgentStatusEvent,
9
19
  INITIAL_STATUS,
10
20
  type ReasoningTokenProvenance,
21
+ type RunTally,
11
22
  type StatusPhase,
12
23
  type TurnStopReason,
13
24
  type TurnSummary,
@@ -0,0 +1,87 @@
1
+ import type { ReasoningTokenProvenance, RunTally, TurnSummary } from "./types.js";
2
+
3
+ /**
4
+ * One projection of a turn's reasoning spend for every surface that shows it.
5
+ *
6
+ * Reasoning counts used to be derived in three places (the status tally, the
7
+ * chat panel's own `message_end` re-derivation, and a chars/4 estimate over the
8
+ * visible thinking text), so the transcript, the turn receipt, and the footer
9
+ * could each report a different number for the same turn. Everything now reads
10
+ * `RunTally`/`TurnSummary` through this module, so a surface can differ in
11
+ * layout but never in the number or its provenance.
12
+ *
13
+ * `unmeasured` is a view-only state: nothing has been folded yet, so the count
14
+ * is unknown rather than zero. It never reaches `TurnSummary`, which keeps its
15
+ * persisted `provider | estimated | mixed` vocabulary.
16
+ */
17
+ export type ReasoningProvenance = ReasoningTokenProvenance | "unmeasured";
18
+
19
+ export interface ReasoningUsageView {
20
+ tokens: number;
21
+ provenance: ReasoningProvenance;
22
+ }
23
+
24
+ export const UNMEASURED_REASONING: ReasoningUsageView = { tokens: 0, provenance: "unmeasured" };
25
+
26
+ function provenanceOf(hadProvider: boolean, hadEstimated: boolean): ReasoningProvenance {
27
+ if (hadProvider && hadEstimated) return "mixed";
28
+ if (hadProvider) return "provider";
29
+ if (hadEstimated) return "estimated";
30
+ return "unmeasured";
31
+ }
32
+
33
+ function finiteTokens(value: unknown): number {
34
+ return typeof value === "number" && Number.isFinite(value) ? Math.max(0, value) : 0;
35
+ }
36
+
37
+ /** Live view of the in-flight turn, folded message by message as the run settles. */
38
+ export function reasoningFromTally(tally: RunTally | undefined): ReasoningUsageView {
39
+ if (!tally) return UNMEASURED_REASONING;
40
+ const provenance = provenanceOf(tally.hadProviderReasoning === true, tally.hadEstimatedReasoning === true);
41
+ if (provenance === "unmeasured") return UNMEASURED_REASONING;
42
+ return { tokens: finiteTokens(tally.reasoningTokens), provenance };
43
+ }
44
+
45
+ /** Settled view of a turn, including one replayed from a persisted summary. */
46
+ export function reasoningFromSummary(summary: TurnSummary | undefined): ReasoningUsageView {
47
+ if (!summary || typeof summary.reasoningTokens !== "number") return UNMEASURED_REASONING;
48
+ // A summary persisted before provenance was recorded carries a count and no
49
+ // label. `summaryFromRunTally` writes both together, so this only covers
50
+ // replayed history, where the count came from provider usage.
51
+ return { tokens: finiteTokens(summary.reasoningTokens), provenance: summary.reasoningTokenProvenance ?? "provider" };
52
+ }
53
+
54
+ /**
55
+ * Compact token text for the surfaces that have no formatter of their own. The
56
+ * footer passes its own `formatFooterTokens` so its chips keep the width
57
+ * budget the rest of the footer is measured against.
58
+ */
59
+ export function compactReasoningTokens(value: number): string {
60
+ if (value < 1000) return String(Math.round(value));
61
+ if (value < 1_000_000) {
62
+ const scaled = (value / 1000).toFixed(1);
63
+ return `${scaled.endsWith(".0") ? scaled.slice(0, -2) : scaled}k`;
64
+ }
65
+ const scaled = (value / 1_000_000).toFixed(1);
66
+ return `${scaled.endsWith(".0") ? scaled.slice(0, -2) : scaled}M`;
67
+ }
68
+
69
+ /**
70
+ * `r123` for a provider-attested count, `r≈123` for anything Clio inferred.
71
+ * Null when there is nothing to state: an unmeasured turn, or one that spent no
72
+ * reasoning tokens at all (a chip reading `r0` names the provenance of zero).
73
+ */
74
+ export function formatReasoningChip(
75
+ view: ReasoningUsageView,
76
+ format: (value: number) => string = compactReasoningTokens,
77
+ ): string | null {
78
+ if (view.provenance === "unmeasured" || view.tokens <= 0) return null;
79
+ return `r${view.provenance === "provider" ? "" : "≈"}${format(view.tokens)}`;
80
+ }
81
+
82
+ export function formatReasoningLabel(view: ReasoningUsageView): string {
83
+ if (view.provenance === "provider") return "provider-reported";
84
+ if (view.provenance === "estimated") return "estimated";
85
+ if (view.provenance === "mixed") return "mixed";
86
+ return "unmeasured";
87
+ }
@@ -18,6 +18,7 @@ interface UsageLike {
18
18
  output?: number;
19
19
  cacheRead?: number;
20
20
  cacheWrite?: number;
21
+ estimated?: boolean;
21
22
  }
22
23
 
23
24
  function assistantThinkingText(message: AgentMessage): string {
@@ -91,7 +92,10 @@ export function foldMessageIntoRunTally(tally: RunTally, message: AgentMessage):
91
92
  cacheReadTokens: tally.cacheReadTokens + (usage ? finite(usage.cacheRead) : 0),
92
93
  cacheWriteTokens: tally.cacheWriteTokens + (usage ? finite(usage.cacheWrite) : 0),
93
94
  };
94
- const reasoning = usage ? extractReasoningTokens(usage) : null;
95
+ // Interrupted turns carry Clio's own estimated usage object. It retains the
96
+ // completed-record shape, including `reasoning: 0`, but that zero is not a
97
+ // provider attestation and must not suppress the thinking-text fallback.
98
+ const reasoning = usage && usage.estimated !== true ? extractReasoningTokens(usage) : null;
95
99
  if (reasoning !== null) {
96
100
  next.reasoningTokens += reasoning;
97
101
  next.hadProviderReasoning = true;
@@ -102,7 +106,14 @@ export function foldMessageIntoRunTally(tally: RunTally, message: AgentMessage):
102
106
  // reported as mixed rather than silently dropping the unreported block.
103
107
  const estimated = estimateReasoningTextTokens(assistantThinkingText(message));
104
108
  if (estimated !== null) {
105
- next.reasoningTokens += estimated;
109
+ // A chars/4 estimate over displayed thinking text can outrun what the
110
+ // provider says the call generated (summarized reasoning, a rail that
111
+ // re-renders the same block). Reported output is the ceiling for anything
112
+ // inferred: reasoning is part of that output, never more than it. No
113
+ // clamp exists upstream in the adapters, so it lives here, once.
114
+ const reportedOutput =
115
+ usage && typeof usage.output === "number" && Number.isFinite(usage.output) ? Math.max(0, usage.output) : null;
116
+ next.reasoningTokens += reportedOutput === null ? estimated : Math.min(estimated, reportedOutput);
106
117
  next.hadEstimatedReasoning = true;
107
118
  }
108
119
  return next;