@gajae-code/coding-agent 0.11.11 → 0.12.1

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 (344) hide show
  1. package/CHANGELOG.md +131 -4
  2. package/dist/types/async/job-manager.d.ts +17 -3
  3. package/dist/types/cli/read-cli.d.ts +1 -0
  4. package/dist/types/commands/deep-interview.d.ts +9 -0
  5. package/dist/types/commands/harness.d.ts +22 -0
  6. package/dist/types/commands/read.d.ts +6 -0
  7. package/dist/types/config/model-registry.d.ts +4 -0
  8. package/dist/types/config/provider-auth-health.d.ts +14 -0
  9. package/dist/types/config/provider-ranking.d.ts +59 -0
  10. package/dist/types/config/settings-schema.d.ts +83 -1
  11. package/dist/types/config/settings.d.ts +2 -0
  12. package/dist/types/exa/mcp-client.d.ts +10 -1
  13. package/dist/types/exec/bash-executor.d.ts +45 -12
  14. package/dist/types/extensibility/extensions/types.d.ts +38 -4
  15. package/dist/types/extensibility/gjc-plugins/index.d.ts +12 -2
  16. package/dist/types/extensibility/gjc-plugins/installer.d.ts +63 -7
  17. package/dist/types/extensibility/gjc-plugins/lifecycle-reconciliation.d.ts +55 -0
  18. package/dist/types/extensibility/gjc-plugins/lifecycle.d.ts +64 -0
  19. package/dist/types/extensibility/gjc-plugins/runtime-quarantine.d.ts +67 -0
  20. package/dist/types/extensibility/gjc-plugins/session-validation.d.ts +3 -1
  21. package/dist/types/extensibility/gjc-plugins/types.d.ts +124 -0
  22. package/dist/types/extensibility/shared-events.d.ts +6 -0
  23. package/dist/types/gjc-runtime/deep-interview-ambiguity.d.ts +5 -12
  24. package/dist/types/gjc-runtime/deep-interview-recorder.d.ts +17 -32
  25. package/dist/types/gjc-runtime/deep-interview-stage.d.ts +37 -0
  26. package/dist/types/gjc-runtime/deep-interview-state.d.ts +4 -135
  27. package/dist/types/gjc-runtime/gc-runtime.d.ts +1 -1
  28. package/dist/types/gjc-runtime/launch-tmux.d.ts +1 -0
  29. package/dist/types/gjc-runtime/memory-guard-owner-claims.d.ts +47 -0
  30. package/dist/types/gjc-runtime/ralplan-runtime.d.ts +70 -1
  31. package/dist/types/gjc-runtime/state-writer.d.ts +5 -46
  32. package/dist/types/gjc-runtime/team-launch.d.ts +1 -1
  33. package/dist/types/gjc-runtime/team-runtime.d.ts +27 -1
  34. package/dist/types/gjc-runtime/team-store.d.ts +12 -0
  35. package/dist/types/gjc-runtime/team-worker-heartbeat.d.ts +43 -0
  36. package/dist/types/gjc-runtime/team-worker-memory-guard.d.ts +92 -0
  37. package/dist/types/gjc-runtime/tmux-owner-isolation.d.ts +17 -0
  38. package/dist/types/gjc-runtime/ultragoal-receipt-freshness.d.ts +8 -0
  39. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +31 -80
  40. package/dist/types/gjc-runtime/workflow-manifest.d.ts +3 -5
  41. package/dist/types/internal-urls/local-root-gc.d.ts +2 -0
  42. package/dist/types/main.d.ts +1 -1
  43. package/dist/types/memories/index.d.ts +2 -0
  44. package/dist/types/modes/acp/acp-agent.d.ts +10 -19
  45. package/dist/types/modes/components/assistant-message.d.ts +3 -1
  46. package/dist/types/modes/components/gjc-bundle-settings.d.ts +34 -0
  47. package/dist/types/modes/components/irc-sidebar.d.ts +41 -0
  48. package/dist/types/modes/components/notifications-settings-editor.d.ts +1 -0
  49. package/dist/types/modes/components/read-tool-group.d.ts +4 -4
  50. package/dist/types/modes/components/session-observer-overlay.d.ts +22 -0
  51. package/dist/types/modes/components/settings-selector.d.ts +11 -0
  52. package/dist/types/modes/components/status-line/gh.d.ts +5 -0
  53. package/dist/types/modes/components/tool-execution.d.ts +8 -0
  54. package/dist/types/modes/components/transcript-viewer-overlay.d.ts +18 -1
  55. package/dist/types/modes/controllers/event-controller.d.ts +3 -0
  56. package/dist/types/modes/interactive-mode.d.ts +2 -0
  57. package/dist/types/modes/irc-observation-ledger.d.ts +17 -1
  58. package/dist/types/modes/types.d.ts +2 -0
  59. package/dist/types/modes/utils/ui-helpers.d.ts +16 -0
  60. package/dist/types/reminders/star-reminder.d.ts +3 -13
  61. package/dist/types/runtime-mcp/manager.d.ts +1 -0
  62. package/dist/types/runtime-mcp/smithery-auth.d.ts +5 -0
  63. package/dist/types/sdk/acp/adapter.d.ts +3 -1
  64. package/dist/types/sdk/broker/lifecycle.d.ts +5 -0
  65. package/dist/types/sdk/broker/transport.d.ts +2 -0
  66. package/dist/types/sdk/bus/attachment-registry.d.ts +1 -0
  67. package/dist/types/sdk/bus/chat-daemon-control.d.ts +4 -1
  68. package/dist/types/sdk/bus/config.d.ts +6 -2
  69. package/dist/types/sdk/bus/control-drain-lease.d.ts +52 -0
  70. package/dist/types/sdk/bus/index.d.ts +44 -1
  71. package/dist/types/sdk/bus/kind-aware-reconciliation.d.ts +44 -0
  72. package/dist/types/sdk/bus/lifecycle-control-runtime.d.ts +4 -0
  73. package/dist/types/sdk/bus/prompt-reconciliation.d.ts +2 -1
  74. package/dist/types/sdk/bus/reconciliation-store.d.ts +69 -0
  75. package/dist/types/sdk/bus/rich-draft.d.ts +1 -0
  76. package/dist/types/sdk/bus/rich-render.d.ts +2 -0
  77. package/dist/types/sdk/bus/telegram-adoption-intent.d.ts +165 -0
  78. package/dist/types/sdk/bus/telegram-cli.d.ts +12 -0
  79. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +11 -4
  80. package/dist/types/sdk/bus/telegram-daemon.d.ts +25 -1
  81. package/dist/types/sdk/bus/telegram-reference.d.ts +11 -1
  82. package/dist/types/sdk/bus/threaded-render.d.ts +3 -2
  83. package/dist/types/sdk/bus/topic-registry.d.ts +11 -1
  84. package/dist/types/sdk/host/control/operations.d.ts +1 -1
  85. package/dist/types/sdk/host/host.d.ts +2 -0
  86. package/dist/types/sdk/host/query/handlers.d.ts +5 -0
  87. package/dist/types/sdk/host/query/revision-store.d.ts +2 -0
  88. package/dist/types/sdk/host/reverse-leases.d.ts +1 -1
  89. package/dist/types/sdk/prompt-status.d.ts +23 -3
  90. package/dist/types/sdk/session.d.ts +6 -0
  91. package/dist/types/session/agent-session.d.ts +82 -12
  92. package/dist/types/session/artifacts.d.ts +8 -0
  93. package/dist/types/session/blob-store.d.ts +57 -4
  94. package/dist/types/session/internal/managed-session-storage.d.ts +8 -1
  95. package/dist/types/session/memory-guard-checkpoint-participant.d.ts +69 -0
  96. package/dist/types/session/session-manager.d.ts +48 -19
  97. package/dist/types/session/session-storage.d.ts +4 -0
  98. package/dist/types/session/streaming-output.d.ts +96 -12
  99. package/dist/types/setup/credential-import.d.ts +12 -0
  100. package/dist/types/skill-state/active-state.d.ts +12 -4
  101. package/dist/types/skill-state/workflow-hud.d.ts +9 -0
  102. package/dist/types/task/executor.d.ts +3 -2
  103. package/dist/types/task/index.d.ts +3 -1
  104. package/dist/types/task/provider-retry-status.d.ts +8 -0
  105. package/dist/types/task/receipt.d.ts +7 -1
  106. package/dist/types/task/types.d.ts +23 -0
  107. package/dist/types/tools/ask-answer-registry.d.ts +2 -2
  108. package/dist/types/tools/bash-interactive.d.ts +4 -1
  109. package/dist/types/tools/bash.d.ts +3 -1
  110. package/dist/types/tools/browser/launch.d.ts +7 -0
  111. package/dist/types/tools/fetch.d.ts +17 -1
  112. package/dist/types/tools/image-gen.d.ts +4 -0
  113. package/dist/types/tools/index.d.ts +4 -0
  114. package/dist/types/tools/output-meta.d.ts +44 -3
  115. package/dist/types/tools/path-utils.d.ts +1 -0
  116. package/dist/types/tools/read-internals.d.ts +12 -0
  117. package/dist/types/tools/read.d.ts +45 -7
  118. package/dist/types/tools/resource-gc.d.ts +4 -0
  119. package/dist/types/tools/review.d.ts +5 -7
  120. package/dist/types/tools/subagent.d.ts +2 -0
  121. package/dist/types/tools/tool-result.d.ts +7 -1
  122. package/dist/types/utils/gh.d.ts +13 -0
  123. package/dist/types/utils/image-resize.d.ts +1 -0
  124. package/dist/types/web/search/providers/kimi.d.ts +2 -0
  125. package/dist/types/web/search/providers/searxng.d.ts +7 -0
  126. package/dist/types/web/search/providers/xai.d.ts +2 -0
  127. package/package.json +11 -13
  128. package/scripts/acp-conformance-agent.ts +218 -0
  129. package/scripts/benchmark-sticky-viewport-pr1.ts +239 -0
  130. package/scripts/capture-gjc-bundle-settings.ts +306 -0
  131. package/scripts/capture-sticky-viewport-showcase.ts +292 -0
  132. package/scripts/generate-sdk-operation-inventory.ts +5 -0
  133. package/scripts/resident-memory-bench.ts +1341 -0
  134. package/scripts/run-acp-conformance.ts +285 -0
  135. package/scripts/verify-gjc-sdk-canonicalization.ts +122 -18
  136. package/scripts/verify-sticky-viewport-showcase.ts +611 -0
  137. package/src/async/job-manager.ts +78 -20
  138. package/src/capability/index.ts +8 -0
  139. package/src/cli/fast-help.ts +1 -1
  140. package/src/cli/plugin-cli.ts +176 -18
  141. package/src/cli/read-cli.ts +2 -1
  142. package/src/cli.ts +1 -0
  143. package/src/commands/deep-interview.ts +29 -9
  144. package/src/commands/harness.ts +35 -12
  145. package/src/commands/read.ts +11 -2
  146. package/src/commands/team.ts +20 -2
  147. package/src/config/file-lock.ts +11 -2
  148. package/src/config/model-registry.ts +21 -2
  149. package/src/config/provider-auth-health.ts +42 -0
  150. package/src/config/provider-ranking.ts +119 -0
  151. package/src/config/settings-schema.ts +74 -1
  152. package/src/config/settings.ts +246 -108
  153. package/src/coordinator-mcp/question-gate-codec.ts +1 -1
  154. package/src/coordinator-mcp/server.ts +5 -12
  155. package/src/defaults/gjc/skills/deep-interview/SKILL.md +230 -82
  156. package/src/defaults/gjc/skills/ralplan/SKILL.md +77 -41
  157. package/src/defaults/gjc/skills/team/SKILL.md +3 -1
  158. package/src/defaults/gjc/skills/ultragoal/SKILL.md +62 -39
  159. package/src/defaults/gjc/skills/ultragoal/validation-batch-contracts.md +12 -0
  160. package/src/defaults/gjc-defaults.ts +4 -4
  161. package/src/eval/py/executor.ts +7 -2
  162. package/src/exa/mcp-client.ts +12 -3
  163. package/src/exec/bash-executor.ts +144 -27
  164. package/src/extensibility/extensions/runner.ts +15 -0
  165. package/src/extensibility/extensions/types.ts +46 -5
  166. package/src/extensibility/gjc-plugins/constrained-hooks.ts +8 -2
  167. package/src/extensibility/gjc-plugins/index.ts +19 -2
  168. package/src/extensibility/gjc-plugins/installer.ts +189 -50
  169. package/src/extensibility/gjc-plugins/lifecycle-reconciliation.ts +199 -0
  170. package/src/extensibility/gjc-plugins/lifecycle.ts +626 -0
  171. package/src/extensibility/gjc-plugins/observability.ts +3 -3
  172. package/src/extensibility/gjc-plugins/prompt-appendix.ts +5 -1
  173. package/src/extensibility/gjc-plugins/runtime-adapters.ts +12 -2
  174. package/src/extensibility/gjc-plugins/runtime-quarantine.ts +121 -0
  175. package/src/extensibility/gjc-plugins/schema.ts +84 -12
  176. package/src/extensibility/gjc-plugins/session-validation.ts +9 -3
  177. package/src/extensibility/gjc-plugins/types.ts +141 -0
  178. package/src/extensibility/gjc-plugins/validation.ts +9 -0
  179. package/src/extensibility/shared-events.ts +5 -0
  180. package/src/gjc-runtime/deep-interview-ambiguity.ts +47 -82
  181. package/src/gjc-runtime/deep-interview-recorder.ts +227 -310
  182. package/src/gjc-runtime/deep-interview-runtime.ts +109 -270
  183. package/src/gjc-runtime/deep-interview-stage.ts +1016 -0
  184. package/src/gjc-runtime/deep-interview-state.ts +12 -943
  185. package/src/gjc-runtime/gc-render.ts +1 -0
  186. package/src/gjc-runtime/gc-runtime.ts +11 -1
  187. package/src/gjc-runtime/launch-tmux.ts +25 -6
  188. package/src/gjc-runtime/managed-owner-admission.ts +1 -1
  189. package/src/gjc-runtime/memory-guard-owner-claims.ts +389 -0
  190. package/src/gjc-runtime/ralplan-runtime.ts +1149 -98
  191. package/src/gjc-runtime/state-runtime.ts +2 -1
  192. package/src/gjc-runtime/state-writer.ts +70 -222
  193. package/src/gjc-runtime/team-launch.ts +42 -5
  194. package/src/gjc-runtime/team-runtime.ts +1085 -40
  195. package/src/gjc-runtime/team-store.ts +32 -0
  196. package/src/gjc-runtime/team-worker-heartbeat.ts +149 -0
  197. package/src/gjc-runtime/team-worker-memory-guard.ts +363 -0
  198. package/src/gjc-runtime/team-workers.ts +1 -0
  199. package/src/gjc-runtime/tmux-owner-isolation.ts +28 -0
  200. package/src/gjc-runtime/tmux-sessions.ts +58 -13
  201. package/src/gjc-runtime/ultragoal-receipt-freshness.ts +20 -0
  202. package/src/gjc-runtime/ultragoal-runtime.ts +414 -618
  203. package/src/gjc-runtime/workflow-manifest.generated.json +105 -449
  204. package/src/gjc-runtime/workflow-manifest.ts +38 -247
  205. package/src/hooks/native-skill-hook.ts +17 -4
  206. package/src/hooks/skill-state.ts +1 -23
  207. package/src/internal-urls/docs-index.generated.ts +21 -22
  208. package/src/internal-urls/local-root-gc.ts +127 -0
  209. package/src/main.ts +9 -5
  210. package/src/memories/index.ts +10 -0
  211. package/src/modes/DESIGN.md +98 -0
  212. package/src/modes/acp/acp-agent.ts +339 -191
  213. package/src/modes/acp/acp-event-mapper.ts +14 -7
  214. package/src/modes/acp/acp-mode.ts +39 -3
  215. package/src/modes/components/assistant-message.ts +123 -22
  216. package/src/modes/components/gjc-bundle-settings.ts +508 -0
  217. package/src/modes/components/irc-sidebar.ts +176 -62
  218. package/src/modes/components/model-selector.ts +61 -4
  219. package/src/modes/components/notifications-settings-editor.ts +12 -1
  220. package/src/modes/components/oauth-selector.ts +96 -28
  221. package/src/modes/components/read-tool-group.ts +82 -14
  222. package/src/modes/components/session-observer-overlay.ts +457 -83
  223. package/src/modes/components/settings-selector.ts +116 -23
  224. package/src/modes/components/status-line/gh.ts +16 -0
  225. package/src/modes/components/tool-execution.ts +138 -27
  226. package/src/modes/components/tool-status-header.ts +3 -14
  227. package/src/modes/components/transcript-viewer-overlay.ts +99 -24
  228. package/src/modes/controllers/command-controller.ts +4 -0
  229. package/src/modes/controllers/event-controller.ts +109 -27
  230. package/src/modes/controllers/extension-ui-controller.ts +26 -2
  231. package/src/modes/controllers/input-controller.ts +18 -3
  232. package/src/modes/controllers/selector-controller.ts +127 -36
  233. package/src/modes/interactive-mode.ts +62 -3
  234. package/src/modes/irc-observation-ledger.ts +71 -15
  235. package/src/modes/runtime-init.ts +22 -2
  236. package/src/modes/types.ts +2 -0
  237. package/src/modes/utils/ui-helpers.ts +94 -1
  238. package/src/prompts/agent-fragments/ralplan-persistence.md +4 -3
  239. package/src/prompts/agents/architect.md +9 -1
  240. package/src/prompts/agents/critic.md +9 -0
  241. package/src/prompts/system/eager-todo.md +8 -4
  242. package/src/prompts/system/system-prompt.md +11 -5
  243. package/src/prompts/tools/bash.md +5 -5
  244. package/src/prompts/tools/read.md +4 -2
  245. package/src/prompts/tools/search-tool-bm25.md +5 -0
  246. package/src/prompts/tools/task.md +1 -1
  247. package/src/reminders/star-reminder.ts +4 -42
  248. package/src/runtime-mcp/manager.ts +19 -4
  249. package/src/runtime-mcp/smithery-auth.ts +17 -2
  250. package/src/runtime-mcp/smithery-connect.ts +11 -1
  251. package/src/sdk/acp/adapter.ts +22 -6
  252. package/src/sdk/broker/lifecycle.ts +25 -6
  253. package/src/sdk/broker/transport.ts +35 -3
  254. package/src/sdk/bus/attachment-registry.ts +5 -1
  255. package/src/sdk/bus/chat-daemon-cli.ts +4 -39
  256. package/src/sdk/bus/chat-daemon-control.ts +6 -3
  257. package/src/sdk/bus/config.ts +18 -2
  258. package/src/sdk/bus/control-drain-lease.ts +104 -0
  259. package/src/sdk/bus/index.ts +984 -146
  260. package/src/sdk/bus/kind-aware-reconciliation.ts +317 -0
  261. package/src/sdk/bus/lifecycle-commands.ts +4 -1
  262. package/src/sdk/bus/lifecycle-control-runtime.ts +403 -88
  263. package/src/sdk/bus/prompt-reconciliation.ts +2 -1
  264. package/src/sdk/bus/reconciliation-store.ts +303 -0
  265. package/src/sdk/bus/rich-draft.ts +1 -1
  266. package/src/sdk/bus/rich-render.ts +2 -2
  267. package/src/sdk/bus/telegram-adoption-intent.ts +574 -0
  268. package/src/sdk/bus/telegram-cli.ts +33 -8
  269. package/src/sdk/bus/telegram-daemon-cli.ts +1 -0
  270. package/src/sdk/bus/telegram-daemon-contract.ts +11 -4
  271. package/src/sdk/bus/telegram-daemon.ts +916 -23
  272. package/src/sdk/bus/telegram-reference.ts +40 -3
  273. package/src/sdk/bus/threaded-render.ts +48 -3
  274. package/src/sdk/bus/topic-registry.ts +20 -0
  275. package/src/sdk/host/control/dispatch.ts +5 -1
  276. package/src/sdk/host/control/operations.ts +1 -1
  277. package/src/sdk/host/host.ts +23 -1
  278. package/src/sdk/host/query/handlers.ts +55 -0
  279. package/src/sdk/host/query/revision-store.ts +36 -2
  280. package/src/sdk/host/reverse-leases.ts +44 -7
  281. package/src/sdk/mcp/server.ts +46 -18
  282. package/src/sdk/prompt-status.ts +16 -3
  283. package/src/sdk/protocol/operation-inventory.generated.json +51 -0
  284. package/src/sdk/protocol/operation-registry.ts +7 -2
  285. package/src/sdk/session.ts +101 -10
  286. package/src/session/agent-session.ts +1286 -220
  287. package/src/session/artifacts.ts +22 -0
  288. package/src/session/auth-broker-config.ts +10 -3
  289. package/src/session/blob-store.ts +915 -24
  290. package/src/session/internal/managed-session-scope.ts +44 -6
  291. package/src/session/internal/managed-session-storage.ts +48 -6
  292. package/src/session/memory-guard-checkpoint-participant.ts +131 -0
  293. package/src/session/session-manager.ts +2364 -438
  294. package/src/session/session-storage.ts +9 -1
  295. package/src/session/streaming-output.ts +500 -111
  296. package/src/setup/credential-import.ts +75 -13
  297. package/src/setup/provider-onboarding.ts +19 -4
  298. package/src/skill-state/active-state.ts +32 -10
  299. package/src/skill-state/workflow-hud.ts +42 -2
  300. package/src/skill-state/workflow-mutation-guard.ts +560 -8
  301. package/src/task/executor.ts +122 -39
  302. package/src/task/index.ts +23 -14
  303. package/src/task/provider-retry-status.ts +16 -0
  304. package/src/task/receipt.ts +23 -7
  305. package/src/task/render.ts +6 -2
  306. package/src/task/types.ts +108 -0
  307. package/src/tools/ask-answer-registry.ts +12 -6
  308. package/src/tools/ask.ts +169 -118
  309. package/src/tools/ast-edit.ts +1 -0
  310. package/src/tools/ast-grep.ts +1 -0
  311. package/src/tools/bash-interactive.ts +8 -3
  312. package/src/tools/bash.ts +503 -60
  313. package/src/tools/browser/launch.ts +41 -6
  314. package/src/tools/fetch.ts +114 -23
  315. package/src/tools/find.ts +1 -0
  316. package/src/tools/image-gen.ts +29 -5
  317. package/src/tools/index.ts +4 -0
  318. package/src/tools/output-meta.ts +281 -20
  319. package/src/tools/path-utils.ts +8 -3
  320. package/src/tools/read-internals.ts +44 -0
  321. package/src/tools/read.ts +1560 -310
  322. package/src/tools/resource-gc.ts +182 -18
  323. package/src/tools/review.ts +5 -7
  324. package/src/tools/search.ts +1 -0
  325. package/src/tools/subagent-render.ts +6 -2
  326. package/src/tools/subagent.ts +6 -0
  327. package/src/tools/telegram-send.ts +6 -3
  328. package/src/tools/tool-result.ts +14 -1
  329. package/src/utils/gh.ts +39 -0
  330. package/src/utils/image-resize.ts +3 -0
  331. package/src/web/search/providers/anthropic.ts +4 -4
  332. package/src/web/search/providers/codex.ts +1 -1
  333. package/src/web/search/providers/kimi.ts +15 -5
  334. package/src/web/search/providers/searxng.ts +47 -4
  335. package/src/web/search/providers/xai.ts +7 -2
  336. package/dist/types/gjc-runtime/deep-interview-draft.d.ts +0 -8
  337. package/dist/types/gjc-runtime/deep-interview-payload.d.ts +0 -41
  338. package/dist/types/gjc-runtime/deep-interview-repair.d.ts +0 -17
  339. package/dist/types/gjc-runtime/ultragoal-pipeline.d.ts +0 -23
  340. package/src/defaults/gjc/skills/ultragoal/pipeline-validation-contracts.md +0 -30
  341. package/src/gjc-runtime/deep-interview-draft.ts +0 -843
  342. package/src/gjc-runtime/deep-interview-payload.ts +0 -262
  343. package/src/gjc-runtime/deep-interview-repair.ts +0 -1674
  344. package/src/gjc-runtime/ultragoal-pipeline.ts +0 -306
@@ -28,6 +28,27 @@ const NL = "\n";
28
28
 
29
29
  const ELLIPSIS = "…";
30
30
 
31
+ const ARTIFACT_WRITER_DIAGNOSTIC_MAX_BYTES = 256;
32
+
33
+ /**
34
+ * Terminal publisher used when managed artifact storage exposes an ID but no
35
+ * writable pathname. The sink supplies raw content retained up to its artifact
36
+ * hard cap; `omittedBytes` is the source-byte count already omitted by that cap.
37
+ * A successful publisher may report additional omitted bytes (for example, a
38
+ * downstream storage cap); those counts are added to the sink summary. Failed
39
+ * and unavailable results are surfaced through the bounded artifact diagnostic
40
+ * without exposing an unresolvable artifact URL.
41
+ */
42
+ export type TerminalArtifactPublishResult =
43
+ | { status: "published"; artifactId: string; omittedBytes?: number }
44
+ | { status: "unavailable" }
45
+ | { status: "failed"; diagnostic: string };
46
+
47
+ export type TerminalArtifactPublisher = (
48
+ content: string,
49
+ info: { totalBytes: number; omittedBytes: number },
50
+ ) => Promise<TerminalArtifactPublishResult>;
51
+
31
52
  // =============================================================================
32
53
  // Interfaces
33
54
  // =============================================================================
@@ -47,10 +68,12 @@ export interface OutputSummary {
47
68
  columnDroppedBytes?: number;
48
69
  /** Number of distinct lines that hit the per-line column cap. */
49
70
  columnTruncatedLines?: number;
50
- /** Artifact ID for internal URL access (artifact://<id>) when truncated */
71
+ /** Artifact ID for internal URL access (artifact://<id>) when output was persisted. */
51
72
  artifactId?: string;
52
73
  /** Bytes omitted from artifact storage after the artifact hard cap was reached. */
53
74
  artifactTruncatedBytes?: number;
75
+ /** Bounded diagnostic when artifact writer or terminal publisher creation, write, finalization, or publication failed. */
76
+ artifactFailureDiagnostic?: string;
54
77
  }
55
78
 
56
79
  export interface OutputSinkOptions {
@@ -60,6 +83,12 @@ export interface OutputSinkOptions {
60
83
  */
61
84
  artifactPath?: string;
62
85
  artifactId?: string;
86
+ /**
87
+ * Optional terminal publisher for managed artifact stores that do not expose a
88
+ * writable path. It is invoked only when visible spill/truncation requires an
89
+ * artifact, and receives raw content bounded by `artifactMaxBytes`.
90
+ */
91
+ artifactPublisher?: TerminalArtifactPublisher;
63
92
  /** Tail buffer budget (bytes). Default DEFAULT_MAX_BYTES. */
64
93
  spillThreshold?: number;
65
94
  /**
@@ -114,8 +143,12 @@ export interface TruncationResult {
114
143
  elidedLines?: number;
115
144
  lastLinePartial?: boolean;
116
145
  firstLineExceedsLimit?: boolean;
146
+ lastLineExceedsLimit?: boolean;
117
147
  }
118
148
 
149
+ /** Direction vocabulary used by callers that select which end of content to retain. */
150
+ export type TruncationDirection = "head" | "last" | "both";
151
+
119
152
  export interface TruncationOptions {
120
153
  /** Maximum number of lines (default: 3000) */
121
154
  maxLines?: number;
@@ -131,6 +164,8 @@ export interface TruncationOptions {
131
164
  * window receives `maxLines - maxHeadLines`. Default `floor(maxLines/2)`.
132
165
  */
133
166
  maxHeadLines?: number;
167
+ /** Direction to dispatch through truncateContent (head, last, or both). */
168
+ direction?: TruncationDirection;
134
169
  }
135
170
 
136
171
  /** Result from byte-level truncation helpers. */
@@ -452,6 +487,7 @@ export function truncateTail(content: string, options: TruncationOptions = {}):
452
487
  outputBytes: tail.bytes,
453
488
  lastLinePartial: true,
454
489
  firstLineExceedsLimit: false,
490
+ lastLineExceedsLimit: true,
455
491
  };
456
492
  }
457
493
  break;
@@ -474,6 +510,7 @@ export function truncateTail(content: string, options: TruncationOptions = {}):
474
510
  outputBytes: tail.bytes,
475
511
  lastLinePartial: true,
476
512
  firstLineExceedsLimit: false,
513
+ lastLineExceedsLimit: true,
477
514
  };
478
515
  }
479
516
  break;
@@ -516,14 +553,129 @@ export function formatMiddleElisionMarker(elidedLines: number, elidedBytes: numb
516
553
  }
517
554
 
518
555
  /**
519
- * Truncate content keeping a head window and a tail window, eliding the middle.
556
+ * A retained source segment returned by {@link truncateMiddleWindows}.
557
+ * Line coordinates are 1-indexed and inclusive.
558
+ */
559
+ export type ReadSegment =
560
+ | {
561
+ kind: "lines";
562
+ content: string;
563
+ lines: number;
564
+ bytes: number;
565
+ origin: { startLine: number; endLine: number };
566
+ lastLinePartial: false;
567
+ }
568
+ | {
569
+ kind: "partial-line";
570
+ content: string;
571
+ lines: 1;
572
+ bytes: number;
573
+ origin: { startLine: number; endLine: number };
574
+ sourceLineBytes: number;
575
+ lastLinePartial: true;
576
+ };
577
+
578
+ /** The actual windows retained by middle truncation. */
579
+ export interface ReadWindow {
580
+ kind: "full" | "head-only" | "tail-only" | "middle";
581
+ head?: ReadSegment;
582
+ tail?: ReadSegment;
583
+ // With normalized head/tail line budgets, an overlap cannot occur: a
584
+ // source that can fit both windows returns `full`, otherwise their kept
585
+ // line counts sum to less than the source. Retain this defensive value for
586
+ // custom/future budgets and wire compatibility.
587
+ overlap: "disjoint" | "adjacent" | "overlapping";
588
+ elidedLines: number;
589
+ elidedBytes: number;
590
+ totalLines: number;
591
+ totalBytes: number;
592
+ /** @deprecated Non-enumerable compatibility reason for older consumers. */
593
+ truncatedBy?: "lines" | "bytes" | "middle";
594
+ }
595
+
596
+ function outputLineCount(result: TruncationResult): number {
597
+ return result.outputLines ?? (result.content.length === 0 ? 0 : countNewlines(result.content) + 1);
598
+ }
599
+
600
+ function outputByteCount(result: TruncationResult): number {
601
+ return result.outputBytes ?? Buffer.byteLength(result.content, "utf-8");
602
+ }
603
+
604
+ function lastSourceLineBytes(content: string): number {
605
+ const lineStart = content.lastIndexOf(NL) + 1;
606
+ return Buffer.byteLength(content.slice(lineStart), "utf-8");
607
+ }
608
+
609
+ function makeReadSegment(
610
+ result: TruncationResult,
611
+ startLine: number,
612
+ totalLines: number,
613
+ sourceContent: string = result.content,
614
+ ): ReadSegment | undefined {
615
+ const lines = outputLineCount(result);
616
+ if (lines <= 0) return undefined;
617
+ const bytes = outputByteCount(result);
618
+ const endLine = Math.min(totalLines, startLine + lines - 1);
619
+ if (result.lastLinePartial) {
620
+ const partialLine = endLine;
621
+ return {
622
+ kind: "partial-line",
623
+ content: result.content,
624
+ lines: 1,
625
+ bytes,
626
+ origin: { startLine: partialLine, endLine: partialLine },
627
+ sourceLineBytes: lastSourceLineBytes(sourceContent),
628
+ lastLinePartial: true,
629
+ };
630
+ }
631
+ return {
632
+ kind: "lines",
633
+ content: result.content,
634
+ lines,
635
+ bytes,
636
+ origin: { startLine, endLine },
637
+ lastLinePartial: false,
638
+ };
639
+ }
640
+
641
+ function withMiddleWindowReason(windows: ReadWindow, truncatedBy: "lines" | "bytes" | "middle"): ReadWindow {
642
+ Object.defineProperty(windows, "truncatedBy", { value: truncatedBy, enumerable: false });
643
+ return windows;
644
+ }
645
+
646
+ function fullReadWindow(
647
+ content: string,
648
+ totalLines: number,
649
+ totalBytes: number,
650
+ overlap: ReadWindow["overlap"] = "disjoint",
651
+ ): ReadWindow {
652
+ return {
653
+ kind: "full",
654
+ head: {
655
+ kind: "lines",
656
+ content,
657
+ lines: totalLines,
658
+ bytes: totalBytes,
659
+ origin: { startLine: 1, endLine: totalLines },
660
+ lastLinePartial: false,
661
+ },
662
+ overlap,
663
+ elidedLines: 0,
664
+ elidedBytes: 0,
665
+ totalLines,
666
+ totalBytes,
667
+ };
668
+ }
669
+
670
+ /**
671
+ * Truncate content while exposing the exact retained head/tail windows.
520
672
  *
521
- * The combined output is `<head>\n<marker>\n<tail>` when truncation is needed.
522
- * `maxHeadBytes` defaults to `floor(maxBytes / 2)`; the tail receives the
523
- * remainder. Falls back to `truncateTail` / `truncateHead` if either side's
524
- * budget is empty or the content already fits.
673
+ * The budget split and fallback ordering intentionally mirror truncateMiddle.
674
+ * The overlap classification is performed before resolving an overlap to a
675
+ * full window, so callers can distinguish disjoint, adjacent and overlapping
676
+ * candidate windows even when no marker is needed.
525
677
  */
526
- export function truncateMiddle(content: string, options: TruncationOptions = {}): TruncationResult {
678
+ export function truncateMiddleWindows(content: string, options: TruncationOptions = {}): ReadWindow {
527
679
  const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
528
680
  const maxLines = options.maxLines ?? DEFAULT_MAX_LINES;
529
681
  const headBytes = options.maxHeadBytes ?? Math.floor(maxBytes / 2);
@@ -533,57 +685,153 @@ export function truncateMiddle(content: string, options: TruncationOptions = {})
533
685
 
534
686
  const totalBytes = Buffer.byteLength(content, "utf-8");
535
687
  const totalLines = countNewlines(content) + 1;
688
+ if (totalBytes <= maxBytes && totalLines <= maxLines) return fullReadWindow(content, totalLines, totalBytes);
689
+
690
+ const oneSided = (kind: "head-only" | "tail-only", result: TruncationResult, startLine: number): ReadWindow => {
691
+ const lines = outputLineCount(result);
692
+ const bytes = outputByteCount(result);
693
+ const segment = makeReadSegment(result, startLine, totalLines, content);
694
+
695
+ const windows: ReadWindow = {
696
+ kind,
697
+ ...(kind === "head-only" ? (segment ? { head: segment } : {}) : segment ? { tail: segment } : {}),
698
+ overlap: "disjoint",
699
+ elidedLines: Math.max(0, totalLines - lines),
700
+ elidedBytes: Math.max(0, totalBytes - bytes),
701
+ totalLines,
702
+ totalBytes,
703
+ };
704
+ return withMiddleWindowReason(windows, result.truncatedBy === "lines" ? "lines" : "bytes");
705
+ };
536
706
 
537
- if (totalBytes <= maxBytes && totalLines <= maxLines) {
538
- return noTruncResult(content, totalLines, totalBytes);
539
- }
540
-
541
- // Degenerate budgets → fall back to one-sided truncation.
707
+ // Keep this order identical to truncateMiddle: degenerate budgets are
708
+ // resolved before either one-sided truncator is called.
542
709
  if (headBytes <= 0 || headLines <= 0) {
543
- return truncateTail(content, { maxBytes: tailBytes || maxBytes, maxLines: tailLines || maxLines });
710
+ const tail = truncateTail(content, { maxBytes: tailBytes || maxBytes, maxLines: tailLines || maxLines });
711
+ return oneSided("tail-only", tail, totalLines - outputLineCount(tail) + 1);
544
712
  }
545
713
  if (tailBytes <= 0 || tailLines <= 0) {
546
- return truncateHead(content, { maxBytes: headBytes, maxLines: headLines });
714
+ const head = truncateHead(content, { maxBytes: headBytes, maxLines: headLines });
715
+ return oneSided("head-only", head, 1);
547
716
  }
548
717
 
549
718
  const head = truncateHead(content, { maxBytes: headBytes, maxLines: headLines });
550
719
  const tail = truncateTail(content, { maxBytes: tailBytes, maxLines: tailLines });
551
-
552
- const headLinesKept = head.outputLines ?? 0;
553
- const tailLinesKept = tail.outputLines ?? 0;
554
- const headBytesKept = head.outputBytes ?? Buffer.byteLength(head.content, "utf-8");
555
- const tailBytesKept = tail.outputBytes ?? Buffer.byteLength(tail.content, "utf-8");
720
+ const headLinesKept = outputLineCount(head);
721
+ const tailLinesKept = outputLineCount(tail);
722
+ const headBytesKept = outputByteCount(head);
723
+ const tailBytesKept = outputByteCount(tail);
556
724
 
557
725
  // Head unusable (first line exceeds budget) → tail-only.
558
- if (headLinesKept === 0 || head.firstLineExceedsLimit) return tail;
726
+ if (headLinesKept === 0 || head.firstLineExceedsLimit) {
727
+ return oneSided("tail-only", tail, totalLines - tailLinesKept + 1);
728
+ }
559
729
  // Tail unusable → head-only.
560
- if (tailLinesKept === 0) return head;
561
- // Windows overlap → no meaningful elision; return content untruncated.
562
- if (headLinesKept + tailLinesKept >= totalLines) {
563
- return noTruncResult(content, totalLines, totalBytes);
730
+ if (tailLinesKept === 0) return oneSided("head-only", head, 1);
731
+
732
+ const headEnd = headLinesKept;
733
+ const tailStart = totalLines - tailLinesKept + 1;
734
+ const overlap: ReadWindow["overlap"] =
735
+ headEnd + 1 < tailStart ? "disjoint" : headEnd + 1 === tailStart ? "adjacent" : "overlapping";
736
+ const tailPartial = tail.lastLinePartial === true;
737
+
738
+ // Adjacent/overlapping complete windows cover the whole source. Return one
739
+ // full segment so renderers never insert a marker for an overlap.
740
+ if (headLinesKept + tailLinesKept >= totalLines && !tailPartial) {
741
+ return fullReadWindow(content, totalLines, totalBytes, overlap);
564
742
  }
565
743
 
566
- const elidedLines = totalLines - headLinesKept - tailLinesKept;
567
- // `totalBytes - headBytesKept - tailBytesKept` includes newline separators
568
- // between the kept windows and the elided region; close enough for a notice.
569
- const elidedBytes = Math.max(0, totalBytes - headBytesKept - tailBytesKept);
570
- const marker = formatMiddleElisionMarker(elidedLines, elidedBytes);
571
- const composed = `${head.content}\n${marker}\n${tail.content}`;
572
- const markerBytes = Buffer.byteLength(marker, "utf-8");
744
+ const headWindow = makeReadSegment(head, 1, totalLines, content);
745
+ const tailWindow = makeReadSegment(tail, tailStart, totalLines, content);
746
+ if (!headWindow || !tailWindow) return fullReadWindow(content, totalLines, totalBytes, overlap);
747
+
748
+ const partialSourceBytes = tailWindow.kind === "partial-line" ? tailWindow.sourceLineBytes : tailWindow.bytes;
749
+ const elidedLines = tailPartial
750
+ ? Math.max(0, tailStart - headEnd - 1)
751
+ : Math.max(0, totalLines - headLinesKept - tailLinesKept);
752
+ const elidedBytes = tailPartial
753
+ ? Math.max(0, totalBytes - headBytesKept - partialSourceBytes)
754
+ : Math.max(0, totalBytes - headBytesKept - tailBytesKept);
755
+ return withMiddleWindowReason(
756
+ {
757
+ kind: "middle",
758
+ head: headWindow,
759
+ tail: tailWindow,
760
+ overlap,
761
+ elidedLines,
762
+ elidedBytes,
763
+ totalLines,
764
+ totalBytes,
765
+ },
766
+ "middle",
767
+ );
768
+ }
573
769
 
574
- return {
575
- content: composed,
576
- truncated: true,
577
- truncatedBy: "middle",
578
- totalLines,
579
- totalBytes,
580
- outputLines: headLinesKept + tailLinesKept + 1,
581
- outputBytes: headBytesKept + tailBytesKept + markerBytes + 2,
582
- elidedLines,
583
- elidedBytes,
584
- lastLinePartial: tail.lastLinePartial,
585
- firstLineExceedsLimit: false,
586
- };
770
+ /**
771
+ * Truncate content keeping a head window and a tail window, eliding the middle.
772
+ * The composed return shape remains field-for-field compatible with the
773
+ * historical implementation; callers that need coordinates use
774
+ * truncateMiddleWindows directly.
775
+ */
776
+ export function truncateMiddle(content: string, options: TruncationOptions = {}): TruncationResult {
777
+ const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
778
+ const maxLines = options.maxLines ?? DEFAULT_MAX_LINES;
779
+ const headBytes = options.maxHeadBytes ?? Math.floor(maxBytes / 2);
780
+ const tailBytes = Math.max(0, maxBytes - headBytes);
781
+ const headLines = options.maxHeadLines ?? Math.max(1, Math.floor(maxLines / 2));
782
+ const tailLines = Math.max(0, maxLines - headLines);
783
+ const windows = truncateMiddleWindows(content, options);
784
+
785
+ switch (windows.kind) {
786
+ case "full":
787
+ return noTruncResult(content, windows.totalLines, windows.totalBytes);
788
+ case "tail-only":
789
+ return truncateTail(content, { maxBytes: tailBytes || maxBytes, maxLines: tailLines || maxLines });
790
+ case "head-only":
791
+ return truncateHead(content, { maxBytes: headBytes, maxLines: headLines });
792
+ case "middle": {
793
+ const head = windows.head;
794
+ const tail = windows.tail;
795
+ if (!head || !tail) return noTruncResult(content, windows.totalLines, windows.totalBytes);
796
+ if (head.lines + tail.lines >= windows.totalLines)
797
+ return noTruncResult(content, windows.totalLines, windows.totalBytes);
798
+ const legacyElidedLines = Math.max(0, windows.totalLines - head.lines - tail.lines);
799
+ const legacyElidedBytes =
800
+ tail.kind === "partial-line"
801
+ ? Math.max(0, windows.totalBytes - head.bytes - tail.bytes)
802
+ : windows.elidedBytes;
803
+ const marker = formatMiddleElisionMarker(legacyElidedLines, legacyElidedBytes);
804
+ const markerBytes = Buffer.byteLength(marker, "utf-8");
805
+ return {
806
+ content: `${head.content}\n${marker}\n${tail.content}`,
807
+ truncated: true,
808
+ truncatedBy: "middle",
809
+ totalLines: windows.totalLines,
810
+ totalBytes: windows.totalBytes,
811
+ outputLines: head.lines + tail.lines + 1,
812
+ outputBytes: head.bytes + tail.bytes + markerBytes + 2,
813
+ elidedLines: legacyElidedLines,
814
+ elidedBytes: legacyElidedBytes,
815
+ lastLinePartial: tail.lastLinePartial,
816
+ firstLineExceedsLimit: false,
817
+ ...(tail.lastLinePartial ? { lastLineExceedsLimit: true } : {}),
818
+ };
819
+ }
820
+ }
821
+ }
822
+
823
+ /**
824
+ * Dispatch truncation using the caller-facing direction vocabulary.
825
+ */
826
+ export function truncateContent(content: string, options: TruncationOptions = {}): TruncationResult {
827
+ switch (options.direction) {
828
+ case "last":
829
+ return truncateTail(content, options);
830
+ case "both":
831
+ return truncateMiddle(content, options);
832
+ default:
833
+ return truncateHead(content, options);
834
+ }
587
835
  }
588
836
 
589
837
  // =============================================================================
@@ -694,6 +942,9 @@ export class OutputSink {
694
942
  #artifactBytes = 0;
695
943
  #artifactTruncatedBytes = 0;
696
944
  #artifactTruncationNoticeWritten = false;
945
+ #artifactFailureDiagnostic?: string;
946
+ #publishedArtifactId?: string;
947
+ #artifactFinalizationFailed = false;
697
948
 
698
949
  // Per-line column cap streaming state (persists across `push` calls so a
699
950
  // long line split across chunks still trips the same trigger).
@@ -708,9 +959,13 @@ export class OutputSink {
708
959
  };
709
960
 
710
961
  // Raw prefix chunks not yet confirmed written to the file sink. This queue is
711
- // the only artifact replay source; retained head/tail windows are lossy views.
962
+ // the pathname-backed artifact replay source; retained head/tail windows are lossy views.
712
963
  #pendingFileWrites?: string[];
713
964
  #pendingFileWriteBytes = 0;
965
+ // Raw chunks retained for a terminal publisher. This queue is capped by
966
+ // `#artifactMaxBytes` and is never used by pathname-backed sinks.
967
+ #pendingArtifactContent?: string[];
968
+ #artifactPublishAttempted = false;
714
969
  #finalized = false;
715
970
 
716
971
  #fileReady = false;
@@ -724,6 +979,7 @@ export class OutputSink {
724
979
  readonly #chunkThrottleMs: number;
725
980
  readonly #maxColumns: number;
726
981
  readonly #artifactMaxBytes: number;
982
+ readonly #artifactPublisher?: TerminalArtifactPublisher;
727
983
  readonly #coalesceSanitize: boolean;
728
984
  #coalesceBuf = "";
729
985
 
@@ -738,6 +994,7 @@ export class OutputSink {
738
994
  chunkThrottleMs = 0,
739
995
  onRawChunk,
740
996
  artifactMaxBytes = DEFAULT_ARTIFACT_MAX_BYTES,
997
+ artifactPublisher,
741
998
  coalesceSanitize = process.env.PI_OUTPUT_SANITIZE_COALESCE === "1",
742
999
  } = options ?? {};
743
1000
  // Managed callers omit artifactPath at the allocation boundary; explicit callers
@@ -751,6 +1008,7 @@ export class OutputSink {
751
1008
  this.#onRawChunk = onRawChunk;
752
1009
  this.#chunkThrottleMs = chunkThrottleMs;
753
1010
  this.#artifactMaxBytes = Math.max(0, artifactMaxBytes);
1011
+ this.#artifactPublisher = artifactPublisher;
754
1012
  this.#coalesceSanitize = coalesceSanitize;
755
1013
  }
756
1014
 
@@ -841,11 +1099,14 @@ export class OutputSink {
841
1099
  // open, keep an independent raw replay prefix because retained head/tail
842
1100
  // windows are trimmed and cannot reconstruct byte-correct artifacts.
843
1101
  if (this.#artifactPath && this.#maxColumns === 0) this.#enqueueFileWrite(rawChunk, rawBytes);
1102
+ else if (this.#artifactPublisher && this.#maxColumns === 0)
1103
+ this.#enqueueTerminalArtifactChunk(rawChunk, rawBytes);
844
1104
 
845
1105
  if (rawBytes === 0) return;
846
1106
 
847
1107
  const visibleChunk = this.#maxColumns > 0 ? this.#applyColumnCap(sanitizedChunk) : sanitizedChunk;
848
1108
  if (this.#artifactPath && this.#maxColumns > 0) this.#enqueueFileWrite(rawChunk, rawBytes);
1109
+ else if (this.#artifactPublisher && this.#maxColumns > 0) this.#enqueueTerminalArtifactChunk(rawChunk, rawBytes);
849
1110
  if (this.#columnDroppedBytes > 0) this.#createFileSink();
850
1111
  const visibleBytes = Buffer.byteLength(visibleChunk, "utf-8");
851
1112
  if (visibleChunk.length > 0) {
@@ -967,6 +1228,45 @@ export class OutputSink {
967
1228
  return `\n[artifact truncated after ${this.#artifactBytes} bytes; omitted at least ${droppedBytes} bytes]\n`;
968
1229
  }
969
1230
 
1231
+ #recordArtifactFailure(phase: "create" | "write" | "end", error: unknown): void {
1232
+ if (this.#artifactFailureDiagnostic) return;
1233
+ const message = (error instanceof Error ? error.message : String(error)).replace(/\s+/gu, " ").trim();
1234
+ const normalized = message || "unknown storage error";
1235
+ this.#artifactFailureDiagnostic = truncateHeadBytes(
1236
+ `${phase}: ${normalized}`,
1237
+ ARTIFACT_WRITER_DIAGNOSTIC_MAX_BYTES,
1238
+ ).text;
1239
+ }
1240
+
1241
+ #recordTerminalArtifactDiagnostic(kind: "unavailable" | "failed", error?: unknown): void {
1242
+ if (this.#artifactFailureDiagnostic) return;
1243
+ const message =
1244
+ error === undefined
1245
+ ? "terminal artifact publisher unavailable"
1246
+ : (error instanceof Error ? error.message : String(error)).replace(/\s+/gu, " ").trim() ||
1247
+ "unknown publication error";
1248
+ this.#artifactFailureDiagnostic = truncateHeadBytes(
1249
+ `${kind}: ${message}`,
1250
+ ARTIFACT_WRITER_DIAGNOSTIC_MAX_BYTES,
1251
+ ).text;
1252
+ }
1253
+
1254
+ // A failed write may have committed only a prefix, so the replay queue is no longer trustworthy.
1255
+ #failArtifact(phase: "create" | "write" | "end", error: unknown, sink?: Bun.FileSink): void {
1256
+ this.#recordArtifactFailure(phase, error);
1257
+ this.#artifactFinalizationFailed = true;
1258
+ this.#file = undefined;
1259
+ this.#fileReady = false;
1260
+ this.#pendingFileWrites = undefined;
1261
+ this.#pendingFileWriteBytes = 0;
1262
+ if (!sink) return;
1263
+ try {
1264
+ void Promise.resolve(sink.end()).catch(endError => this.#recordArtifactFailure("end", endError));
1265
+ } catch (endError) {
1266
+ this.#recordArtifactFailure("end", endError);
1267
+ }
1268
+ }
1269
+
970
1270
  #capArtifactChunk(chunk: string, bytes: number): { chunk: string; bytes: number } | null {
971
1271
  if (bytes === 0) return null;
972
1272
  if (this.#artifactMaxBytes <= 0 || this.#artifactBytes >= this.#artifactMaxBytes) {
@@ -982,18 +1282,23 @@ export class OutputSink {
982
1282
  return kept.bytes > 0 ? { chunk: kept.text, bytes: kept.bytes } : null;
983
1283
  }
984
1284
 
985
- #writeArtifactTruncationNotice(): void {
986
- if (this.#artifactTruncatedBytes <= 0 || this.#artifactTruncationNoticeWritten) return;
1285
+ async #writeArtifactTruncationNotice(): Promise<void> {
1286
+ if (
1287
+ this.#artifactFinalizationFailed ||
1288
+ this.#artifactTruncatedBytes <= 0 ||
1289
+ this.#artifactTruncationNoticeWritten
1290
+ )
1291
+ return;
987
1292
  const notice = this.#artifactTruncationNotice(this.#artifactTruncatedBytes);
988
1293
  try {
989
1294
  if (this.#fileReady && this.#file) {
990
- this.#file.sink.write(notice);
1295
+ await this.#file.sink.write(notice);
991
1296
  } else {
992
1297
  this.#queuePendingFileWrite(notice, Buffer.byteLength(notice, "utf-8"));
993
1298
  }
994
1299
  this.#artifactTruncationNoticeWritten = true;
995
- } catch {
996
- /* ignore */
1300
+ } catch (error) {
1301
+ this.#failArtifact("write", error, this.#file?.sink);
997
1302
  }
998
1303
  }
999
1304
 
@@ -1005,6 +1310,7 @@ export class OutputSink {
1005
1310
  }
1006
1311
 
1007
1312
  #enqueueFileWrite(chunk: string, bytes: number): void {
1313
+ if (this.#artifactFinalizationFailed || this.#finalized) return;
1008
1314
  const capped = this.#capArtifactChunk(chunk, bytes);
1009
1315
  if (!capped) return;
1010
1316
  this.#artifactBytes += capped.bytes;
@@ -1016,51 +1322,113 @@ export class OutputSink {
1016
1322
 
1017
1323
  try {
1018
1324
  this.#file.sink.write(capped.chunk);
1019
- } catch {
1020
- try {
1021
- void this.#file.sink.end();
1022
- } catch {
1023
- /* ignore */
1024
- }
1025
- this.#file = undefined;
1026
- this.#fileReady = false;
1027
- this.#queuePendingFileWrite(capped.chunk, capped.bytes);
1028
- this.#createFileSink();
1325
+ } catch (error) {
1326
+ this.#failArtifact("write", error, this.#file.sink);
1327
+ }
1328
+ }
1329
+
1330
+ #enqueueTerminalArtifactChunk(chunk: string, bytes: number): void {
1331
+ if (this.#artifactFinalizationFailed || this.#finalized) return;
1332
+ const capped = this.#capArtifactChunk(chunk, bytes);
1333
+ if (!capped) return;
1334
+ this.#artifactBytes += capped.bytes;
1335
+ if (!this.#pendingArtifactContent) this.#pendingArtifactContent = [capped.chunk];
1336
+ else this.#pendingArtifactContent.push(capped.chunk);
1337
+ const pending = this.#pendingArtifactContent;
1338
+ if (!pending) return;
1339
+ if (pending.length > MAX_PENDING) {
1340
+ pending[0] = pending.join("");
1341
+ pending.length = 1;
1029
1342
  }
1030
1343
  }
1031
1344
 
1032
1345
  #createFileSink(): boolean {
1033
- if (this.#finalized) return false;
1346
+ if (this.#finalized || this.#artifactFinalizationFailed) return false;
1034
1347
 
1035
1348
  if (!this.#artifactPath) return false;
1036
1349
  if (this.#fileReady) return this.#file != null;
1350
+
1351
+ let sink: Bun.FileSink;
1037
1352
  try {
1038
- const sink = Bun.file(this.#artifactPath).writer();
1039
- this.#file = { path: this.#artifactPath, artifactId: this.#artifactId, sink };
1353
+ sink = Bun.file(this.#artifactPath).writer();
1354
+ } catch (error) {
1355
+ this.#recordArtifactFailure("create", error);
1356
+ return false;
1357
+ }
1358
+ this.#file = { path: this.#artifactPath, artifactId: this.#artifactId, sink };
1040
1359
 
1360
+ try {
1041
1361
  const pending = this.#pendingFileWrites;
1042
1362
  if (pending) {
1043
1363
  for (const chunk of pending) sink.write(chunk);
1044
1364
  }
1365
+ } catch (error) {
1366
+ this.#failArtifact("write", error, sink);
1367
+ return false;
1368
+ }
1045
1369
 
1046
- this.#fileReady = true;
1047
- this.#pendingFileWrites = undefined;
1048
- this.#pendingFileWriteBytes = 0;
1370
+ this.#fileReady = true;
1371
+ this.#pendingFileWrites = undefined;
1372
+ this.#pendingFileWriteBytes = 0;
1373
+ return true;
1374
+ }
1375
+
1376
+ #shouldPublishTerminalArtifact(): boolean {
1377
+ return (
1378
+ this.#artifactPublisher !== undefined &&
1379
+ this.#artifactPath === undefined &&
1380
+ (this.#truncated || this.#columnDroppedBytes > 0 || this.#artifactTruncatedBytes > 0)
1381
+ );
1382
+ }
1049
1383
 
1050
- return true;
1051
- } catch {
1052
- try {
1053
- void this.#file?.sink?.end();
1054
- } catch {
1055
- /* ignore */
1384
+ async #publishTerminalArtifact(): Promise<string | undefined> {
1385
+ if (
1386
+ !this.#shouldPublishTerminalArtifact() ||
1387
+ this.#artifactPublishAttempted ||
1388
+ this.#artifactFinalizationFailed
1389
+ ) {
1390
+ return this.#publishedArtifactId;
1391
+ }
1392
+ this.#artifactPublishAttempted = true;
1393
+ const content = this.#pendingArtifactContent?.join("") ?? "";
1394
+ const omittedBytes = this.#artifactTruncatedBytes;
1395
+ try {
1396
+ const published = await this.#artifactPublisher!(content, {
1397
+ totalBytes: this.#totalBytes,
1398
+ omittedBytes,
1399
+ });
1400
+ if (published.status === "unavailable") {
1401
+ this.#recordTerminalArtifactDiagnostic("unavailable");
1402
+ this.#artifactFinalizationFailed = true;
1403
+ return undefined;
1056
1404
  }
1057
- this.#file = undefined;
1058
- // Keep #pendingFileWriteBytes in sync with the preserved queue so
1059
- // later retry/threshold decisions don't undercount retained bytes.
1060
- this.#pendingFileWriteBytes = this.#pendingFileWrites
1061
- ? this.#pendingFileWrites.reduce((sum, chunk) => sum + Buffer.byteLength(chunk), 0)
1062
- : 0;
1063
- return false;
1405
+ if (published.status === "failed") {
1406
+ this.#recordTerminalArtifactDiagnostic("failed", published.diagnostic);
1407
+ this.#artifactFinalizationFailed = true;
1408
+ return undefined;
1409
+ }
1410
+ if (published.artifactId.length === 0) {
1411
+ this.#recordTerminalArtifactDiagnostic("failed", "storage returned no artifact id");
1412
+ this.#artifactFinalizationFailed = true;
1413
+ return undefined;
1414
+ }
1415
+ if (published.omittedBytes !== undefined) {
1416
+ if (!Number.isSafeInteger(published.omittedBytes) || published.omittedBytes < 0) {
1417
+ this.#recordTerminalArtifactDiagnostic("failed", "publisher returned invalid omitted-byte accounting");
1418
+ this.#artifactFinalizationFailed = true;
1419
+ return undefined;
1420
+ }
1421
+ this.#artifactTruncatedBytes += published.omittedBytes;
1422
+ }
1423
+ this.#publishedArtifactId = published.artifactId;
1424
+ this.#finalized = true;
1425
+ return this.#publishedArtifactId;
1426
+ } catch (error) {
1427
+ this.#recordTerminalArtifactDiagnostic("failed", error);
1428
+ this.#artifactFinalizationFailed = true;
1429
+ return undefined;
1430
+ } finally {
1431
+ this.#pendingArtifactContent = undefined;
1064
1432
  }
1065
1433
  }
1066
1434
 
@@ -1083,25 +1451,34 @@ export class OutputSink {
1083
1451
  * minimizer rewrites the captured output after the raw bytes have already
1084
1452
  * been streamed.
1085
1453
  *
1086
- * After this call the buffer is authoritative: streaming counters realign
1087
- * to the replacement, the retained head window is cleared, and head
1088
- * retention is disabled so subsequent `push()` calls append directly to the
1089
- * tail buffer instead of repopulating the (now meaningless) head window
1090
- * — which would otherwise reorder content and trip the middle-elision
1091
- * branch in `dump()` against stale totals.
1454
+ * After this call the replacement is authoritative: counters reflect its full
1455
+ * size, the configured head+tail byte windows are retained with UTF-8-safe
1456
+ * boundaries, and future head accumulation is disabled so later `push()` calls
1457
+ * append directly to the tail without reordering the replacement.
1092
1458
  */
1093
1459
  replace(text: string): void {
1094
1460
  this.#coalesceBuf = "";
1095
- this.#setTail(text);
1461
+ const replacementBytes = Buffer.byteLength(text, "utf-8");
1096
1462
  this.#head = "";
1097
1463
  this.#headBytes = 0;
1464
+ this.#setTail("");
1465
+
1466
+ if (this.#headLimit > 0) {
1467
+ const head = truncateHeadBytes(text, this.#headLimit);
1468
+ this.#appendHead(head.text, head.bytes);
1469
+ const tailText = text.substring(head.text.length);
1470
+ this.#setTail(tailText, replacementBytes - head.bytes);
1471
+ } else {
1472
+ this.#setTail(text, replacementBytes);
1473
+ }
1474
+ this.#trimTailTo(this.#spillThreshold);
1098
1475
  this.#headRetentionDisabled = true;
1099
- this.#totalBytes = this.#bufferBytes;
1100
- this.#processedBytes = this.#bufferBytes;
1476
+ this.#totalBytes = replacementBytes;
1477
+ this.#processedBytes = replacementBytes;
1101
1478
  this.#totalLines = countNewlines(text);
1102
1479
  this.#processedLines = this.#totalLines;
1103
1480
  this.#sawData = text.length > 0;
1104
- this.#truncated = false;
1481
+ this.#truncated = replacementBytes > this.#headBytes + this.#bufferBytes;
1105
1482
  this.#currentLineBytes = 0;
1106
1483
  this.#columnEllipsisAdded = false;
1107
1484
  this.#columnDroppedBytes = 0;
@@ -1114,18 +1491,35 @@ export class OutputSink {
1114
1491
  const totalLines = this.#sawData ? this.#totalLines + 1 : 0;
1115
1492
 
1116
1493
  let artifactId: string | undefined;
1117
- if (this.#artifactTruncatedBytes > 0) this.#createFileSink();
1118
- this.#writeArtifactTruncationNotice();
1119
- if (this.#file) {
1120
- artifactId = this.#file.artifactId;
1121
- await this.#file.sink.end();
1122
- this.#finalized = true;
1494
+ if (this.#finalized) {
1495
+ artifactId = this.#publishedArtifactId ?? this.#artifactId;
1496
+ } else if (!this.#artifactFinalizationFailed) {
1497
+ if (this.#artifactPublisher && !this.#artifactPath) {
1498
+ artifactId = await this.#publishTerminalArtifact();
1499
+ }
1500
+ if (!this.#artifactFinalizationFailed && this.#artifactPath) {
1501
+ if (this.#artifactTruncatedBytes > 0) this.#createFileSink();
1502
+ await this.#writeArtifactTruncationNotice();
1503
+ if (this.#file) {
1504
+ try {
1505
+ await this.#file.sink.end();
1506
+ artifactId = this.#file.artifactId;
1507
+ this.#finalized = true;
1508
+ } catch (error) {
1509
+ this.#recordArtifactFailure("end", error);
1510
+ this.#artifactFinalizationFailed = true;
1511
+ this.#file = undefined;
1512
+ this.#fileReady = false;
1513
+ }
1514
+ }
1515
+ }
1123
1516
  }
1124
1517
  if (this.#finalized) {
1125
1518
  // Terminal: the artifact is closed; replay state is no longer needed.
1126
1519
  this.#pendingFileWrites = undefined;
1127
1520
  this.#pendingFileWriteBytes = 0;
1128
1521
  this.#fileReady = false;
1522
+ this.#pendingArtifactContent = undefined;
1129
1523
  }
1130
1524
  // Non-finalized dumps (no artifact sink ever opened) keep the raw replay
1131
1525
  // queue so a later post-dump push that spills produces a CUMULATIVE
@@ -1151,24 +1545,18 @@ export class OutputSink {
1151
1545
  let elidedLines: number | undefined;
1152
1546
 
1153
1547
  if (headBytes > 0 && effectiveTotalBytes > headBytes + tailBytes) {
1154
- // Middle was elided. Emit head + marker + tail.
1548
+ // Middle was elided. Emit both explicit windows, including when they are
1549
+ // fragments of one source line (the marker still carries byte evidence).
1155
1550
  elidedBytes = Math.max(0, effectiveTotalBytes - headBytes - tailBytes);
1156
1551
  elidedLines = Math.max(0, processedTotalLines - headLines - tailLines);
1157
- if (elidedLines === 0) {
1158
- body = headText;
1159
- outputBytes = headBytes;
1160
- outputLines = headLines;
1161
- this.#truncated = true;
1162
- } else {
1163
- const marker = formatMiddleElisionMarker(elidedLines, elidedBytes);
1164
- const markerBytes = Buffer.byteLength(marker, "utf-8");
1165
- const headSep = headText.endsWith("\n") ? "" : "\n";
1166
- const tailSep = tailBuf.startsWith("\n") ? "" : "\n";
1167
- body = `${headText}${headSep}${marker}${tailSep}${tailBuf}`;
1168
- outputBytes = headBytes + markerBytes + tailBytes + headSep.length + tailSep.length;
1169
- outputLines = headLines + 1 + tailLines;
1170
- this.#truncated = true;
1171
- }
1552
+ const marker = formatMiddleElisionMarker(elidedLines, elidedBytes);
1553
+ const markerBytes = Buffer.byteLength(marker, "utf-8");
1554
+ const headSep = headText.endsWith("\n") ? "" : "\n";
1555
+ const tailSep = tailBuf.startsWith("\n") ? "" : "\n";
1556
+ body = `${headText}${headSep}${marker}${tailSep}${tailBuf}`;
1557
+ outputBytes = headBytes + markerBytes + tailBytes + headSep.length + tailSep.length;
1558
+ outputLines = headLines + 1 + tailLines;
1559
+ this.#truncated = true;
1172
1560
  } else if (headBytes > 0) {
1173
1561
  // Head + tail combine into the full buffered output (no overlap or elision).
1174
1562
  body = `${headText}${tailBuf}`;
@@ -1193,6 +1581,7 @@ export class OutputSink {
1193
1581
  columnDroppedBytes: this.#columnDroppedBytes > 0 ? this.#columnDroppedBytes : undefined,
1194
1582
  columnTruncatedLines: this.#columnTruncatedLines > 0 ? this.#columnTruncatedLines : undefined,
1195
1583
  artifactTruncatedBytes: this.#artifactTruncatedBytes > 0 ? this.#artifactTruncatedBytes : undefined,
1584
+ artifactFailureDiagnostic: this.#artifactFailureDiagnostic,
1196
1585
  artifactId,
1197
1586
  };
1198
1587
  }