@humwick/runner 0.0.0-stage → 0.130.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 (293) hide show
  1. package/LICENSE +167 -0
  2. package/agent-bin/agent-relay-browser +10 -0
  3. package/agent-bin/humwick +10 -0
  4. package/hooks/fff-search-redirect/fff-search-redirect.cjs +161 -0
  5. package/hooks/fff-search-redirect/hooks.json +13 -0
  6. package/hooks/rg-r-guard/hooks.json +18 -0
  7. package/hooks/rg-r-guard/rg-r-guard.cjs +144 -0
  8. package/hooks/web-tools-redirect/hooks.json +13 -0
  9. package/hooks/web-tools-redirect/web-tools-redirect.cjs +84 -0
  10. package/node_modules/@humwick/offload/LICENSE +167 -0
  11. package/node_modules/@humwick/offload/README.md +57 -0
  12. package/node_modules/@humwick/offload/dist/async-runner.d.ts +18 -0
  13. package/node_modules/@humwick/offload/dist/async-runner.d.ts.map +1 -0
  14. package/node_modules/@humwick/offload/dist/async-runner.js +158 -0
  15. package/node_modules/@humwick/offload/dist/async-runner.js.map +1 -0
  16. package/node_modules/@humwick/offload/dist/envelope.d.ts +25 -0
  17. package/node_modules/@humwick/offload/dist/envelope.d.ts.map +1 -0
  18. package/node_modules/@humwick/offload/dist/envelope.js +80 -0
  19. package/node_modules/@humwick/offload/dist/envelope.js.map +1 -0
  20. package/node_modules/@humwick/offload/dist/fixtures/worker-task.d.ts +10 -0
  21. package/node_modules/@humwick/offload/dist/fixtures/worker-task.d.ts.map +1 -0
  22. package/node_modules/@humwick/offload/dist/fixtures/worker-task.js +59 -0
  23. package/node_modules/@humwick/offload/dist/fixtures/worker-task.js.map +1 -0
  24. package/node_modules/@humwick/offload/dist/index.d.ts +5 -0
  25. package/node_modules/@humwick/offload/dist/index.d.ts.map +1 -0
  26. package/node_modules/@humwick/offload/dist/index.js +5 -0
  27. package/node_modules/@humwick/offload/dist/index.js.map +1 -0
  28. package/node_modules/@humwick/offload/dist/subprocess-runner.d.ts +62 -0
  29. package/node_modules/@humwick/offload/dist/subprocess-runner.d.ts.map +1 -0
  30. package/node_modules/@humwick/offload/dist/subprocess-runner.js +295 -0
  31. package/node_modules/@humwick/offload/dist/subprocess-runner.js.map +1 -0
  32. package/node_modules/@humwick/offload/dist/types.d.ts +111 -0
  33. package/node_modules/@humwick/offload/dist/types.d.ts.map +1 -0
  34. package/node_modules/@humwick/offload/dist/types.js +176 -0
  35. package/node_modules/@humwick/offload/dist/types.js.map +1 -0
  36. package/node_modules/@humwick/offload/dist/worker-pool.d.ts +23 -0
  37. package/node_modules/@humwick/offload/dist/worker-pool.d.ts.map +1 -0
  38. package/node_modules/@humwick/offload/dist/worker-pool.js +435 -0
  39. package/node_modules/@humwick/offload/dist/worker-pool.js.map +1 -0
  40. package/node_modules/@humwick/offload/dist/worker-runtime.d.ts +2 -0
  41. package/node_modules/@humwick/offload/dist/worker-runtime.d.ts.map +1 -0
  42. package/node_modules/@humwick/offload/dist/worker-runtime.js +27 -0
  43. package/node_modules/@humwick/offload/dist/worker-runtime.js.map +1 -0
  44. package/node_modules/@humwick/offload/dist/worker.d.ts +5 -0
  45. package/node_modules/@humwick/offload/dist/worker.d.ts.map +1 -0
  46. package/node_modules/@humwick/offload/dist/worker.js +1 -0
  47. package/node_modules/@humwick/offload/dist/worker.js.map +1 -0
  48. package/node_modules/@humwick/offload/package.json +42 -0
  49. package/node_modules/@humwick/offload/src/async-runner.ts +170 -0
  50. package/node_modules/@humwick/offload/src/envelope.ts +92 -0
  51. package/node_modules/@humwick/offload/src/fixtures/worker-task.ts +49 -0
  52. package/node_modules/@humwick/offload/src/index.ts +4 -0
  53. package/node_modules/@humwick/offload/src/subprocess-runner.ts +334 -0
  54. package/node_modules/@humwick/offload/src/types.ts +235 -0
  55. package/node_modules/@humwick/offload/src/worker-pool.ts +463 -0
  56. package/node_modules/@humwick/offload/src/worker-runtime.ts +32 -0
  57. package/node_modules/@humwick/offload/src/worker.ts +5 -0
  58. package/package.json +47 -4
  59. package/plugins/browser/skills/playwright-cli/SKILL.md +53 -0
  60. package/plugins/claude/.claude-plugin/plugin.json +8 -0
  61. package/plugins/claude/claude-statusline-probe.cjs +144 -0
  62. package/plugins/claude/hooks/gate-run-guard.provisioned.mjs +914 -0
  63. package/plugins/claude/hooks/gate-run-guard.sh +9 -0
  64. package/plugins/claude/hooks/gate-run-guard.ts +237 -0
  65. package/plugins/claude/hooks/hooks.json +158 -0
  66. package/plugins/claude/hooks/kill-guard.provisioned.mjs +7027 -0
  67. package/plugins/claude/hooks/kill-guard.sh +341 -0
  68. package/plugins/claude/hooks/kill-guard.ts +337 -0
  69. package/plugins/claude/hooks/permission-request.sh +22 -0
  70. package/plugins/claude/hooks/post-compact.sh +10 -0
  71. package/plugins/claude/hooks/post-tool-use.sh +8 -0
  72. package/plugins/claude/hooks/pre-compact.sh +12 -0
  73. package/plugins/claude/hooks/read-only-bash-guard.sh +587 -0
  74. package/plugins/claude/hooks/relay-status.sh +373 -0
  75. package/plugins/claude/hooks/session-end.sh +26 -0
  76. package/plugins/claude/hooks/session-start.sh +31 -0
  77. package/plugins/claude/hooks/shell-trust.ts +28 -0
  78. package/plugins/claude/hooks/stop-failure.sh +47 -0
  79. package/plugins/claude/hooks/stop.sh +48 -0
  80. package/plugins/claude/hooks/subagent-guard.cjs +26 -0
  81. package/plugins/claude/hooks/subagent-skill-policy.cjs +160 -0
  82. package/plugins/claude/hooks/subagent-start.sh +13 -0
  83. package/plugins/claude/hooks/subagent-stop.sh +19 -0
  84. package/plugins/claude/hooks/user-prompt-submit.sh +18 -0
  85. package/plugins/claude/hooks/wait-loop-guard.ts +120 -0
  86. package/plugins/claude/monitors/monitors.json +7 -0
  87. package/plugins/claude/monitors/relay-monitor.provisioned.mjs +786 -0
  88. package/plugins/claude/monitors/relay-monitor.ts +58 -0
  89. package/plugins/claude/skills/disconnect/SKILL.md +16 -0
  90. package/plugins/claude/skills/label/SKILL.md +23 -0
  91. package/plugins/claude/skills/message/SKILL.md +40 -0
  92. package/plugins/claude/skills/pair/SKILL.md +30 -0
  93. package/plugins/claude/skills/react/SKILL.md +18 -0
  94. package/plugins/claude/skills/read-message/SKILL.md +24 -0
  95. package/plugins/claude/skills/reply/SKILL.md +44 -0
  96. package/plugins/claude/skills/send-claimable/SKILL.md +39 -0
  97. package/plugins/claude/skills/slice-first/SKILL.md +32 -0
  98. package/plugins/claude/skills/slice-first/references/plan-recipe.md +46 -0
  99. package/plugins/claude/skills/status/SKILL.md +16 -0
  100. package/plugins/claude/skills/tags/SKILL.md +25 -0
  101. package/plugins/claude/skills/task/SKILL.md +28 -0
  102. package/plugins/claude-output-style/.claude-plugin/plugin.json +5 -0
  103. package/plugins/claude-output-style/output-styles/agent-relay.md +5 -0
  104. package/plugins/codex/skills/disconnect/SKILL.md +15 -0
  105. package/plugins/codex/skills/guide/SKILL.md +15 -0
  106. package/plugins/codex/skills/label/SKILL.md +22 -0
  107. package/plugins/codex/skills/message/SKILL.md +40 -0
  108. package/plugins/codex/skills/pair/SKILL.md +29 -0
  109. package/plugins/codex/skills/react/SKILL.md +17 -0
  110. package/plugins/codex/skills/read-message/SKILL.md +23 -0
  111. package/plugins/codex/skills/reply/SKILL.md +44 -0
  112. package/plugins/codex/skills/send-claimable/SKILL.md +39 -0
  113. package/plugins/codex/skills/slice-first/SKILL.md +32 -0
  114. package/plugins/codex/skills/slice-first/references/plan-recipe.md +46 -0
  115. package/plugins/codex/skills/status/SKILL.md +15 -0
  116. package/plugins/codex/skills/tags/SKILL.md +24 -0
  117. package/plugins/codex/skills/task/SKILL.md +28 -0
  118. package/src/adapter.ts +1061 -0
  119. package/src/adapters/__fixtures__/codex-app-server.ts +47 -0
  120. package/src/adapters/claude-api.ts +243 -0
  121. package/src/adapters/claude-context-command.ts +16 -0
  122. package/src/adapters/claude-credentials.ts +22 -0
  123. package/src/adapters/claude-delivery-lanes.ts +28 -0
  124. package/src/adapters/claude-delivery.ts +166 -0
  125. package/src/adapters/claude-effort.ts +94 -0
  126. package/src/adapters/claude-exit.ts +290 -0
  127. package/src/adapters/claude-headless-mirror.ts +88 -0
  128. package/src/adapters/claude-headless-refusal.ts +51 -0
  129. package/src/adapters/claude-headless.ts +617 -0
  130. package/src/adapters/claude-monitor-delivery-plan.ts +317 -0
  131. package/src/adapters/claude-permission.ts +243 -0
  132. package/src/adapters/claude-prompt-gates.ts +269 -0
  133. package/src/adapters/claude-prompt-hook.ts +145 -0
  134. package/src/adapters/claude-session-capture.ts +293 -0
  135. package/src/adapters/claude-session-probe.ts +414 -0
  136. package/src/adapters/claude-shutdown.ts +190 -0
  137. package/src/adapters/claude-status-detectors.ts +426 -0
  138. package/src/adapters/claude-subagents.ts +93 -0
  139. package/src/adapters/claude-submit-confirmation.ts +287 -0
  140. package/src/adapters/claude-tmux.ts +548 -0
  141. package/src/adapters/claude-token-usage-capture.ts +253 -0
  142. package/src/adapters/claude-tool-policy.ts +165 -0
  143. package/src/adapters/claude-transcript-tail.ts +193 -0
  144. package/src/adapters/claude-transcript.ts +812 -0
  145. package/src/adapters/claude.ts +1097 -0
  146. package/src/adapters/codex-agent-message-capture.ts +189 -0
  147. package/src/adapters/codex-app-server.ts +70 -0
  148. package/src/adapters/codex-client.ts +362 -0
  149. package/src/adapters/codex-effective-config.ts +87 -0
  150. package/src/adapters/codex-launch-config.ts +153 -0
  151. package/src/adapters/codex-rollout-context.ts +272 -0
  152. package/src/adapters/codex-skill-discovery.ts +18 -0
  153. package/src/adapters/codex-token-usage-capture.ts +163 -0
  154. package/src/adapters/codex-tool-output.ts +53 -0
  155. package/src/adapters/codex-turn-outcome.ts +27 -0
  156. package/src/adapters/codex-version.ts +64 -0
  157. package/src/adapters/codex.ts +1442 -0
  158. package/src/adapters/incremental-file.ts +110 -0
  159. package/src/adapters/opencode-api.ts +71 -0
  160. package/src/adapters/opencode-context.ts +277 -0
  161. package/src/adapters/opencode-model-registry.ts +126 -0
  162. package/src/adapters/opencode-provider.ts +169 -0
  163. package/src/adapters/opencode-server-retry.ts +50 -0
  164. package/src/adapters/opencode-soft-steer.ts +302 -0
  165. package/src/adapters/opencode-spawn.ts +145 -0
  166. package/src/adapters/opencode-subscribe.ts +62 -0
  167. package/src/adapters/opencode-token-usage-capture.ts +187 -0
  168. package/src/adapters/opencode-tool-events.ts +302 -0
  169. package/src/adapters/opencode-tools-config.ts +17 -0
  170. package/src/adapters/opencode.ts +2264 -0
  171. package/src/adapters/project-shell.ts +80 -0
  172. package/src/adapters/provider-launch-config.ts +21 -0
  173. package/src/adapters/provider-quota-harvest.ts +127 -0
  174. package/src/adapters/service-access.ts +153 -0
  175. package/src/adapters/tmux-offload.ts +207 -0
  176. package/src/adapters/tool-summary.ts +214 -0
  177. package/src/agent-redact.ts +75 -0
  178. package/src/agent-runner/commands/dispatch.ts +185 -0
  179. package/src/agent-runner/commands/lifecycle.ts +84 -0
  180. package/src/agent-runner/commands/prompt.ts +284 -0
  181. package/src/agent-runner/credentials.ts +208 -0
  182. package/src/agent-runner/http-liveness.ts +85 -0
  183. package/src/agent-runner/lifecycle-milestones.ts +207 -0
  184. package/src/agent-runner/lifecycle-phase.ts +80 -0
  185. package/src/agent-runner/registration.ts +163 -0
  186. package/src/agent-runner/runner-context.ts +158 -0
  187. package/src/agent-runner/session-mirror.ts +278 -0
  188. package/src/agent-runner/task-claims.ts +116 -0
  189. package/src/agent-runner/token-usage-coverage.ts +54 -0
  190. package/src/agent-runner/turn-report.ts +269 -0
  191. package/src/api-budget-accounting.ts +91 -0
  192. package/src/area-memory.ts +255 -0
  193. package/src/artifact-path-materialize.ts +52 -0
  194. package/src/async-guard.ts +35 -0
  195. package/src/attachment-cache-sweep.ts +31 -0
  196. package/src/attachment-cache.ts +195 -0
  197. package/src/background-script-reconcile.ts +106 -0
  198. package/src/bounded-log.ts +59 -0
  199. package/src/bounded-spawn-sync.ts +18 -0
  200. package/src/busy-reconciler.ts +282 -0
  201. package/src/claim-tracker.ts +140 -0
  202. package/src/claude-settings.ts +167 -0
  203. package/src/claude-workspace-trust.ts +289 -0
  204. package/src/config.ts +547 -0
  205. package/src/context-boundary.ts +101 -0
  206. package/src/context-command.ts +151 -0
  207. package/src/context-probe-state.ts +6 -0
  208. package/src/continuation-archive.ts +179 -0
  209. package/src/control-server.ts +730 -0
  210. package/src/delivery-policy.ts +9 -0
  211. package/src/delivery-result.ts +24 -0
  212. package/src/effort-command.ts +125 -0
  213. package/src/empty-turn.ts +816 -0
  214. package/src/free-port.ts +25 -0
  215. package/src/fs-offload.ts +26 -0
  216. package/src/gate-run-config.ts +35 -0
  217. package/src/gate-run-lease-probe.ts +38 -0
  218. package/src/host-asset-plan.ts +450 -0
  219. package/src/host-claude-config-worker.ts +34 -0
  220. package/src/host-sources.ts +40 -0
  221. package/src/inbox-carrier.ts +7 -0
  222. package/src/index.ts +386 -0
  223. package/src/launch-assembly.ts +955 -0
  224. package/src/liveness.ts +105 -0
  225. package/src/logger.ts +108 -0
  226. package/src/mcp-outbox.ts +204 -0
  227. package/src/mcp-preflight.ts +86 -0
  228. package/src/mcp-source-selection.ts +54 -0
  229. package/src/mcp-tool-deferral.ts +35 -0
  230. package/src/mcp-vault-env.ts +25 -0
  231. package/src/message-body-config/config.ts +16 -0
  232. package/src/message-body-config.ts +1 -0
  233. package/src/mid-turn-delivery.ts +43 -0
  234. package/src/native-memory-lever.ts +329 -0
  235. package/src/native-self-resume.ts +244 -0
  236. package/src/outbox.ts +453 -0
  237. package/src/park-post.ts +17 -0
  238. package/src/pending-permission-store.ts +148 -0
  239. package/src/process-meta.ts +9 -0
  240. package/src/profile-home.ts +419 -0
  241. package/src/profile-projection.ts +411 -0
  242. package/src/project-hooks.ts +65 -0
  243. package/src/project-instructions.ts +119 -0
  244. package/src/provider-double.ts +59 -0
  245. package/src/provider-hold.ts +228 -0
  246. package/src/provider-home-marker.ts +3 -0
  247. package/src/provider-home-tombstone.ts +4 -0
  248. package/src/provider-input-queue.ts +134 -0
  249. package/src/provider-integration.ts +93 -0
  250. package/src/provider-processes.ts +17 -0
  251. package/src/provider-submit-failure.ts +15 -0
  252. package/src/providers.ts +35 -0
  253. package/src/provisioning.ts +703 -0
  254. package/src/question-decision.ts +18 -0
  255. package/src/quota.ts +22 -0
  256. package/src/rate-limit.ts +351 -0
  257. package/src/refused-prompt-store.ts +36 -0
  258. package/src/relay-injection-events.ts +161 -0
  259. package/src/relay-instructions.ts +107 -0
  260. package/src/relay-mcp-proxy.long-turn-e2e.ts +183 -0
  261. package/src/relay-mcp-proxy.ts +704 -0
  262. package/src/relay-mcp.ts +583 -0
  263. package/src/reply-obligation-cache.ts +211 -0
  264. package/src/response-capture-report.ts +111 -0
  265. package/src/response-routing.ts +6 -0
  266. package/src/runner-core.ts +2955 -0
  267. package/src/runner-exit.ts +12 -0
  268. package/src/runner-helpers.ts +582 -0
  269. package/src/runner-insights.ts +105 -0
  270. package/src/runner-mcp-proxy.ts +46 -0
  271. package/src/runner-spawn-config.ts +68 -0
  272. package/src/runner-stale-scratch-sweep.ts +21 -0
  273. package/src/runner.ts +9 -0
  274. package/src/runtime-token-file.ts +45 -0
  275. package/src/runtime-token-remint.ts +56 -0
  276. package/src/service-access.ts +88 -0
  277. package/src/session-destroy.ts +27 -0
  278. package/src/session-insights.ts +118 -0
  279. package/src/session-scratch.ts +296 -0
  280. package/src/spawn-input.ts +147 -0
  281. package/src/startup-config-fetch.ts +48 -0
  282. package/src/subreaper.ts +113 -0
  283. package/src/template-resolver.ts +29 -0
  284. package/src/test-run-dedup.ts +435 -0
  285. package/src/transcript-context.ts +99 -0
  286. package/src/transport-errors.ts +48 -0
  287. package/src/turn-opener.ts +55 -0
  288. package/src/turn-reconcile-event.ts +88 -0
  289. package/src/vault-proxy-hop.ts +99 -0
  290. package/src/version.ts +44 -0
  291. package/src/web-tool-call-proxy.ts +128 -0
  292. package/src/web-tools.ts +59 -0
  293. package/README.md +0 -3
@@ -0,0 +1,253 @@
1
+ import { readdir } from "node:fs/promises";
2
+ import { basename, dirname, join } from "node:path";
3
+ // #2482 — per-turn Claude token-usage capture: the runner-side half of the token-usage
4
+ // spine's first capture seam. On every Stop-hook session turn (runner-core's
5
+ // publishSessionTurn) the transcript's APPENDED tail is read and every NEW root-session
6
+ // assistant entry's API-reported usage is extracted (the #2482 sibling parser beside
7
+ // extractLatestContextUsage in adapters/claude-transcript.ts) and one spine record is
8
+ // POSTed per entry, keyed dedupeKey=entry-uuid so the spine's dedupe_key upsert makes
9
+ // replays and retries idempotent by construction.
10
+ //
11
+ // Two invariants the capture owns:
12
+ // • NO RE-READ — the per-pass position is a byte offset: each turn reads only the bytes
13
+ // appended since the last pass (partial trailing line buffered until its newline
14
+ // arrives, split on 0x0A so multi-byte UTF-8 survives byte boundaries). Per-turn cost
15
+ // is O(new lines), never O(session).
16
+ // • NO DOUBLE-COUNT — the offset watermark commits only after every POST of the pass
17
+ // landed; a failed POST leaves it untouched so the same slice replays next pass. A
18
+ // transcript-path rotation (a committed /clear mints a new session file), an inode
19
+ // change, or a TRUNCATION (same path, size shrank below the offset) re-reads from
20
+ // byte 0 and REPOSTS — safe by construction, because every record keys on the
21
+ // entry uuid and the spine's dedupe_key upsert collapses reposts onto their row
22
+ // (deliberately NOT ordinal-suppressed: counting entries could silently skip a
23
+ // genuinely new one inside a shortened file).
24
+ //
25
+ // All passes are serialized through a single queued worker: requests arriving mid-pass
26
+ // coalesce onto the running slot and the worker drains the latest requested path when
27
+ // the pass finishes — even when that pass failed (first error still reaches the caller).
28
+ // Concurrent Stops for the same transcript can never double-read or double-post, and a
29
+ // rotation racing a pass converges on the newest file.
30
+
31
+ import { errMessage } from "@humwick/sdk";
32
+ import { claudeSessionIdFromTranscriptPath, TranscriptTokenUsageExtractor, type TranscriptTokenUsageEntry } from "./claude-transcript";
33
+ import { incrementalFileNeedsReset, readIncrementalFileRange, splitIncrementalFileLines, statIncrementalFile, type IncrementalFileInfo, type IncrementalFileLineBuffer, type IncrementalFileLinePolicy, type IncrementalFilePosition } from "./incremental-file";
34
+ import type { ProviderTokenUsageRecord } from "../adapter";
35
+
36
+ /** The spine's turn-less report shape (POST /api/agents/:id/token-usage, #2485). */
37
+ export type TranscriptTokenUsageSpineRecord = ProviderTokenUsageRecord & { provider: "claude" };
38
+
39
+ // Claude assistant transcript entries carry both the full model output and usage. Eight
40
+ // MiB accommodates multi-megabyte normal responses while still bounding a line that is
41
+ // left unterminated by a pathological writer.
42
+ export const CLAUDE_TRANSCRIPT_LINE_POLICY: IncrementalFileLinePolicy = { maxPartialBytes: 8 * 1024 * 1024 };
43
+
44
+ export interface ClaudeTranscriptTokenUsageCaptureDeps {
45
+ subagent?: { id: string; sessionId: string };
46
+ post: (record: TranscriptTokenUsageSpineRecord) => Promise<unknown>;
47
+ stat?: (path: string) => Promise<IncrementalFileInfo>;
48
+ /** Bytes in [start, end) of the transcript file — the incremental tail read. */
49
+ readRange?: (path: string, start: number, end: number) => Promise<Uint8Array>;
50
+ debug?: (message: string) => void;
51
+ }
52
+
53
+ export function spineRecordForEntry(sessionId: string, entry: TranscriptTokenUsageEntry): TranscriptTokenUsageSpineRecord {
54
+ return {
55
+ sessionId,
56
+ provider: "claude",
57
+ model: entry.model,
58
+ inputTokens: entry.inputTokens,
59
+ outputTokens: entry.outputTokens,
60
+ cacheReadTokens: entry.cacheReadTokens,
61
+ cacheWriteTokens: entry.cacheWriteTokens,
62
+ dedupeKey: entry.uuid,
63
+ createdAt: entry.occurredAt,
64
+ ...(entry.usageStatus ? { usageStatus: entry.usageStatus } : {}),
65
+ };
66
+ }
67
+
68
+ export class ClaudeTranscriptTokenUsageCapture {
69
+ private readonly children = new Map<string, ClaudeTranscriptTokenUsageCapture>();
70
+ private childSessionId?: string;
71
+ private position: IncrementalFilePosition | undefined;
72
+ private lineBuffer: IncrementalFileLineBuffer = {};
73
+ private pending: { path: string; promise: Promise<number> } | undefined;
74
+ private settling?: Promise<void>;
75
+
76
+ constructor(private readonly deps: ClaudeTranscriptTokenUsageCaptureDeps) {}
77
+
78
+ /**
79
+ * Report the transcript's not-yet-reported assistant usage entries. Serialized: at
80
+ * most one pass runs at a time; a request arriving mid-pass coalesces onto the queued
81
+ * slot (latest path wins) and the worker drains it after the current pass — even when
82
+ * that pass failed, with the first error still propagated to the caller. Never
83
+ * rejects for an unreadable transcript (debug note; the next pass re-reads); a failed
84
+ * POST propagates to the caller's catch with the position held for a next-pass retry.
85
+ */
86
+ capture(transcriptPath: string): Promise<number> {
87
+ return this.settling ? this.settling.then(() => this.capturePassQueued(transcriptPath)) : this.capturePassQueued(transcriptPath);
88
+ }
89
+
90
+ private capturePassQueued(transcriptPath: string): Promise<number> {
91
+ if (this.pending) {
92
+ this.pending.path = transcriptPath;
93
+ return this.pending.promise;
94
+ }
95
+ const slot: { path: string; promise: Promise<number> } = { path: transcriptPath, promise: Promise.resolve(0) };
96
+ slot.promise = (async () => {
97
+ // Error presence is tracked by flag, not the undefined sentinel: JS permits
98
+ // `throw undefined`, and `??=` would both swallow that pass's error entirely and
99
+ // let a later pass's error masquerade as first (round-4).
100
+ let hasFirstError = false;
101
+ let firstError: unknown;
102
+ let postedCount = 0;
103
+ try {
104
+ do {
105
+ const target = slot.path;
106
+ slot.path = "";
107
+ try {
108
+ postedCount += await this.capturePass(target);
109
+ } catch (error) {
110
+ // Retain and keep draining: a failed pass must not strand the queued
111
+ // latest-path request (round-3). The first error still reaches the caller.
112
+ if (!hasFirstError) {
113
+ firstError = error;
114
+ hasFirstError = true;
115
+ }
116
+ }
117
+ } while (slot.path);
118
+ } finally {
119
+ this.pending = undefined;
120
+ }
121
+ if (hasFirstError) throw firstError;
122
+ return postedCount;
123
+ })();
124
+ this.pending = slot;
125
+ return slot.promise;
126
+ }
127
+
128
+ /**
129
+ * A monitorless pane can render idle before Claude flushes its final assistant
130
+ * JSONL entry. Keep draining the incremental tail through the runner-owned
131
+ * bounded settle window. Once an entry arrives, wait for a short quiet interval
132
+ * too: an older delayed entry must not make us abandon the ending turn's own
133
+ * line when both are being flushed.
134
+ */
135
+ captureWithSettle(transcriptPath: string, options: { pollIntervalMs: number; timeoutMs: number; now?: () => number; sleep?: (ms: number) => Promise<unknown> }): Promise<number> {
136
+ const settle = (this.settling ?? Promise.resolve()).then(() => this.settleTranscript(transcriptPath, options));
137
+ const barrier = settle.then(() => {}, () => {});
138
+ this.settling = barrier;
139
+ void barrier.then(() => { if (this.settling === barrier) this.settling = undefined; });
140
+ return settle;
141
+ }
142
+
143
+ private async settleTranscript(transcriptPath: string, options: { pollIntervalMs: number; timeoutMs: number; now?: () => number; sleep?: (ms: number) => Promise<unknown> }): Promise<number> {
144
+ // now/sleep are injectable so tests can drive the settle window on a virtual
145
+ // clock instead of racing real timers against host load (#2714 flake).
146
+ const now = options.now ?? Date.now;
147
+ const sleep = options.sleep ?? Bun.sleep;
148
+ const deadline = now() + options.timeoutMs;
149
+ const pollIntervalMs = Math.max(1, options.pollIntervalMs);
150
+ const quietWindowMs = Math.min(options.timeoutMs, Math.max(pollIntervalMs * 2, 25));
151
+ // #2491 review round 3 (reverts round 2's watermark bail, which was WRONG — it exited
152
+ // exactly when the watermark hadn't moved yet, which is precisely the state a still-
153
+ // flushing final entry looks like before it lands). Seed the quiet-window baseline at
154
+ // settle START rather than leaving it undefined until something is first found: without
155
+ // this, when nothing EVER arrives (routine — the turn's own Stop-hook pass already
156
+ // caught everything) the quiet-window exit never fires and the loop burns the full
157
+ // settlePollTimeoutMs on every hooked teardown. Seeding it here bounds that case to
158
+ // quietWindowMs of continued silence instead — still comfortably wider than the ms-scale
159
+ // flush race this settle exists to catch, just not the full timeout.
160
+ let lastNewEntryAt: number = now();
161
+
162
+ let reported = await this.capturePassQueued(transcriptPath);
163
+ if (reported) lastNewEntryAt = now();
164
+ while (now() < deadline) {
165
+ await sleep(Math.max(1, Math.min(pollIntervalMs, deadline - now())));
166
+ const count = await this.capturePassQueued(transcriptPath);
167
+ reported += count;
168
+ if (count) {
169
+ lastNewEntryAt = now();
170
+ } else if (now() - lastNewEntryAt >= quietWindowMs) {
171
+ return reported;
172
+ }
173
+ }
174
+ return reported;
175
+ }
176
+
177
+ async captureSubagent(transcriptPath: string, settle = false): Promise<number> {
178
+ if (basename(dirname(transcriptPath)) !== "subagents") throw new Error("Expected a Claude sub-agent transcript");
179
+ const match = /^agent-(.+)\.jsonl$/.exec(basename(transcriptPath));
180
+ if (!match) throw new Error("Expected an agent-<id>.jsonl transcript");
181
+ const sessionId = basename(dirname(dirname(transcriptPath)));
182
+ if (sessionId !== this.childSessionId) { this.children.clear(); this.childSessionId = sessionId; }
183
+ let child = this.children.get(transcriptPath);
184
+ if (!child) {
185
+ child = new ClaudeTranscriptTokenUsageCapture({ ...this.deps, subagent: { id: match[1]!, sessionId } });
186
+ this.children.set(transcriptPath, child);
187
+ }
188
+ if (settle) return child.captureWithSettle(transcriptPath, { pollIntervalMs: 25, timeoutMs: 1_000 });
189
+ return child.capture(transcriptPath);
190
+ }
191
+
192
+ private async capturePass(transcriptPath: string): Promise<number> {
193
+ let count = await this.captureTranscriptPass(transcriptPath);
194
+ if (this.deps.subagent) return count;
195
+ const directory = join(dirname(transcriptPath), basename(transcriptPath, ".jsonl"), "subagents");
196
+ let files;
197
+ try { files = await readdir(directory, { withFileTypes: true }); }
198
+ catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return count; throw error; }
199
+ for (const file of files) {
200
+ if (file.isFile() && /^agent-.+\.jsonl$/.test(file.name)) count += await this.captureSubagent(join(directory, file.name));
201
+ }
202
+ return count;
203
+ }
204
+
205
+ private async captureTranscriptPass(transcriptPath: string): Promise<number> {
206
+ const stat = this.deps.stat ?? statIncrementalFile;
207
+ let info: IncrementalFileInfo;
208
+ try {
209
+ info = await stat(transcriptPath);
210
+ } catch (error) {
211
+ if (this.deps.subagent) throw error;
212
+ this.deps.debug?.(`token-usage capture: transcript unreadable: ${errMessage(error)}`);
213
+ return 0;
214
+ }
215
+
216
+ // New path / inode rotation / same-path truncation (size shrank below the offset):
217
+ // read again from byte 0. Reposts are SAFE, not an error — the spine collapses each
218
+ // one onto its existing row via dedupeKey=entry-uuid — and a genuinely new entry
219
+ // carried by a truncated/shortened file still reports. (Count-based ordinal
220
+ // suppression was tried here and could silently skip such an entry — round-3.)
221
+ if (incrementalFileNeedsReset(this.position, transcriptPath, info)) {
222
+ this.position = { path: transcriptPath, ino: info.ino, offset: 0 };
223
+ this.lineBuffer = {};
224
+ }
225
+ const position = this.position;
226
+ if (!position || info.size <= position.offset) return 0; // nothing appended since the last pass
227
+
228
+ const chunk = await (this.deps.readRange ?? readIncrementalFileRange)(transcriptPath, position.offset, info.size);
229
+ const { lines, buffer } = splitIncrementalFileLines(this.lineBuffer, chunk, CLAUDE_TRANSCRIPT_LINE_POLICY);
230
+
231
+ const extractor = new TranscriptTokenUsageExtractor(0, Boolean(this.deps.subagent));
232
+ const newEntries: TranscriptTokenUsageEntry[] = [];
233
+ for (const line of lines) {
234
+ const entry = extractor.appendLine(line.toString("utf8"));
235
+ if (entry) newEntries.push(entry);
236
+ }
237
+
238
+ const sessionId = this.deps.subagent?.sessionId ?? claudeSessionIdFromTranscriptPath(transcriptPath);
239
+ if (!sessionId) {
240
+ this.deps.debug?.("token-usage capture: no session id in transcript path; skipping");
241
+ return 0;
242
+ }
243
+ for (const entry of newEntries) {
244
+ await this.deps.post({ ...spineRecordForEntry(sessionId, entry), ...(this.deps.subagent ? { subagentId: this.deps.subagent.id } : {}) });
245
+ }
246
+ // Commit only after every POST of the pass landed — a throw above leaves the
247
+ // position and partial buffer untouched, so the whole slice replays next pass
248
+ // (safe: uuid-keyed, spine-deduped).
249
+ position.offset = info.size;
250
+ this.lineBuffer = buffer;
251
+ return newEntries.length;
252
+ }
253
+ }
@@ -0,0 +1,165 @@
1
+ import { writeChildEnv } from "@humwick/sdk/env";
2
+ import { harnessSubagentsEnabled } from "@humwick/sdk";
3
+ import type { RunnerSpawnConfig } from "../adapter";
4
+ import type { AgentProfileProviderOptions } from "@humwick/sdk";
5
+ import { isRecord } from "@humwick/sdk/types/guards";
6
+ import { claudeDisallowedWebTools } from "../web-tools";
7
+
8
+ // ── #2453 — eager "System tools" context trim for the coding/review profiles ─────────────────
9
+ // A BARE tool name on Claude Code's --disallowedTools removes the tool's schema from the
10
+ // model's eager context (the /context "System tools" bucket) WITHOUT touching MCP (mcp__*),
11
+ // skills, or core coding tools — which an allowlist (--tools) would put at risk. The Artifact
12
+ // family + Workflow carry the heaviest eager docstrings and coding/review agents never publish
13
+ // artifacts or run workflows; SendFeedback is misused by autonomous agents (also env-suppressed
14
+ // fleet-wide since T3182 — this is the per-profile context-trim path); ScheduleWakeup is
15
+ // loop-only; ReportFindings duplicates relay report-up.
16
+ export const CLAUDE_CONTEXT_TRIM_DISALLOWED_TOOLS = ["Artifact", "ArtifactComments", "ArtifactData", "ArtifactCheck", "Workflow", "SendFeedback", "ScheduleWakeup", "ReportFindings"];
17
+
18
+ // The coding/review built-ins the denylist defaults ON for: worker/debugger build+test+land,
19
+ // reviewer review, probe one-shot checks. Coordinators and ops-assistant
20
+ // legitimately use Artifact/Workflow/ScheduleWakeup, so they keep every tool. A profile retunes
21
+ // this per-profile via providerOptions.claude.disallowedTools.
22
+ const CLAUDE_CONTEXT_TRIM_DEFAULT_PROFILES = new Set(["worker", "debugger", "reviewer", "probe"]);
23
+
24
+ const CLAUDE_DISALLOWED_TOOLS_ARG = /^--(?:disallowedTools|disallowed-tools)(?:=(.*))?$/;
25
+
26
+ /** Effective #2453 denylist for this launch: a profile-configured array REPLACES the default,
27
+ * `null` opts the profile out entirely, absent falls back to the default — and only for the
28
+ * coding/review profiles. Resolves the profile via the established
29
+ * `agentProfile?.name || profile` pattern. */
30
+ export function claudeProfileDisallowedTools(config: Pick<RunnerSpawnConfig, "profile" | "agentProfile">): string[] {
31
+ const providerOptions = config.agentProfile?.providerOptions;
32
+ // A whole-null claude section is an explicit per-provider opt-out, same as
33
+ // disallowedTools: null — NOT "absent, fall back to the profile default".
34
+ if (isRecord(providerOptions) && "claude" in providerOptions && providerOptions.claude === null) return [];
35
+ const claudeOptions = isRecord(providerOptions) && isRecord(providerOptions.claude) ? providerOptions.claude : undefined;
36
+ if (claudeOptions && "disallowedTools" in claudeOptions) {
37
+ if (claudeOptions.disallowedTools === null) return [];
38
+ if (Array.isArray(claudeOptions.disallowedTools)) {
39
+ return claudeOptions.disallowedTools.filter((tool): tool is string => typeof tool === "string" && tool.length > 0);
40
+ }
41
+ }
42
+ const profileName = config.agentProfile?.name || config.profile || "";
43
+ if (!CLAUDE_CONTEXT_TRIM_DEFAULT_PROFILES.has(profileName)) return [];
44
+ // A profile that switches the Artifact or Workflow tool back on (#3171) gets it.
45
+ const harness = config.agentProfile ? claudeHarnessOptions(config) : CLAUDE_HARNESS_DEFAULTS;
46
+ return CLAUDE_CONTEXT_TRIM_DISALLOWED_TOOLS.filter((tool) => tool === "Workflow" ? !harness.workflows : !tool.startsWith("Artifact") || !harness.artifacts);
47
+ }
48
+
49
+ // ── #3171 — harness context trim: Claude Code built-ins Relay agents never use ──────────────────
50
+ export type ClaudeAgentTypes = NonNullable<NonNullable<AgentProfileProviderOptions["claude"]>["agentTypes"]>;
51
+ const CLAUDE_AGENT_TYPES: readonly ClaudeAgentTypes[] = ["all", "general-purpose", "none"];
52
+ export interface ClaudeHarnessOptions {
53
+ artifacts: boolean;
54
+ workflows: boolean;
55
+ bundledSkills: boolean;
56
+ agentTypes: ClaudeAgentTypes;
57
+ /** AskUserQuestion + ListAgents: tools for a human at the terminal or native peer sessions. */
58
+ interactiveTools: boolean;
59
+ }
60
+
61
+ // Every Relay-launched profile gets this unless its providerOptions.claude says otherwise; the
62
+ // built-in exceptions (coordinator, software-design, probe, ...) live in src/agent-profile-catalog.ts.
63
+ const CLAUDE_HARNESS_DEFAULTS: ClaudeHarnessOptions = { artifacts: false, workflows: false, bundledSkills: false, agentTypes: "general-purpose", interactiveTools: false };
64
+ // Profile-less launches (a human's wrapped `claude`) and a whole-null claude section keep everything.
65
+ const CLAUDE_HARNESS_KEEP_ALL: ClaudeHarnessOptions = { artifacts: true, workflows: true, bundledSkills: true, agentTypes: "all", interactiveTools: true };
66
+
67
+ // Claude Code 2.1.310's bundled skills, grouped by the tool that needs them. The env switch drops
68
+ // them all, so a profile with Artifact or Workflow on turns the others off one by one instead.
69
+ const ARTIFACT_SKILLS = ["artifact-design", "artifact-diagramming", "artifact-capabilities", "dataviz"];
70
+ const WORKFLOW_SKILLS = ["workflow-authoring"];
71
+ const OTHER_BUNDLED_SKILLS = ["claude-api", "code-review", "security-review", "update-config", "schedule", "run", "loop", "keybindings-help", "simplify", "fewer-permission-prompts", "init"];
72
+ // Survives CLAUDE_CODE_DISABLE_BUNDLED_SKILLS; only skillOverrides removes it.
73
+ const PLUGIN_AUTHORING_SKILL = "plugin-authoring";
74
+ // Explore/Plan go through CLAUDE_CODE_DISABLE_EXPLORE_PLAN_AGENTS; these need a deny.
75
+ const NON_GENERAL_AGENT_TYPES = ["claude-code-guide", "statusline-setup", "claude"];
76
+
77
+ export function claudeHarnessOptions(config: Pick<RunnerSpawnConfig, "agentProfile">): ClaudeHarnessOptions {
78
+ const providerOptions = config.agentProfile?.providerOptions;
79
+ if (!config.agentProfile || (isRecord(providerOptions) && "claude" in providerOptions && providerOptions.claude === null)) return CLAUDE_HARNESS_KEEP_ALL;
80
+ const claude = isRecord(providerOptions) && isRecord(providerOptions.claude) ? providerOptions.claude : {};
81
+ const flag = (key: "artifacts" | "workflows" | "bundledSkills" | "interactiveTools") => typeof claude[key] === "boolean" ? claude[key] as boolean : CLAUDE_HARNESS_DEFAULTS[key];
82
+ const agentTypes = CLAUDE_AGENT_TYPES.find((value) => value === claude.agentTypes) ?? CLAUDE_HARNESS_DEFAULTS.agentTypes;
83
+ return { artifacts: flag("artifacts"), workflows: flag("workflows"), bundledSkills: flag("bundledSkills"), agentTypes, interactiveTools: flag("interactiveTools") };
84
+ }
85
+
86
+ export interface ClaudeHarnessTrim {
87
+ env: Record<string, string>;
88
+ /** Keys for the inline --settings payload. */
89
+ settings: Record<string, unknown>;
90
+ /** Bare or `Agent(<type>)` names for --disallowedTools. */
91
+ deny: string[];
92
+ }
93
+
94
+ /** The launch levers for one profile's harness options: env switches, skillOverrides and denies. */
95
+ export function claudeHarnessTrim(config: Pick<RunnerSpawnConfig, "agentProfile">): ClaudeHarnessTrim {
96
+ const options = claudeHarnessOptions(config);
97
+ const env: Record<string, string> = {};
98
+ if (!options.artifacts) env.CLAUDE_CODE_DISABLE_ARTIFACT = "1";
99
+ if (!options.workflows) env.CLAUDE_CODE_DISABLE_WORKFLOWS = "1";
100
+ if (options.agentTypes !== "all") env.CLAUDE_CODE_DISABLE_EXPLORE_PLAN_AGENTS = "1";
101
+ const skillsOff: string[] = [];
102
+ if (!options.bundledSkills) {
103
+ if (options.artifacts || options.workflows) {
104
+ skillsOff.push(...OTHER_BUNDLED_SKILLS, ...(options.artifacts ? [] : ARTIFACT_SKILLS), ...(options.workflows ? [] : WORKFLOW_SKILLS));
105
+ } else {
106
+ env.CLAUDE_CODE_DISABLE_BUNDLED_SKILLS = "1";
107
+ }
108
+ skillsOff.push(PLUGIN_AUTHORING_SKILL);
109
+ }
110
+ const deny = [
111
+ ...(options.interactiveTools ? [] : ["AskUserQuestion", "ListAgents"]),
112
+ ...(options.agentTypes === "none" ? ["Agent"] : options.agentTypes === "general-purpose" ? NON_GENERAL_AGENT_TYPES.map((type) => `Agent(${type})`) : []),
113
+ ];
114
+ return { env, settings: skillsOff.length ? { skillOverrides: Object.fromEntries(skillsOff.map((skill) => [skill, "off"])) } : {}, deny };
115
+ }
116
+
117
+ /** One scan over the launch args: the tool names carried by inherited
118
+ * `--disallowedTools`/`--disallowed-tools` entries (space-separated multi-value and `=`
119
+ * comma-list forms), plus the args with those entries removed — so the profile denylist can
120
+ * UNION with what a caller/provider config already asked for instead of clobbering it. */
121
+ function partitionClaudeDisallowedToolsArgs(args: string[]): { kept: string[]; inherited: string[] } {
122
+ const kept: string[] = [];
123
+ const inherited: string[] = [];
124
+ for (let i = 0; i < args.length; i++) {
125
+ const arg = args[i];
126
+ const match = typeof arg === "string" ? CLAUDE_DISALLOWED_TOOLS_ARG.exec(arg) : null;
127
+ if (!match) {
128
+ kept.push(arg as string);
129
+ continue;
130
+ }
131
+ if (match[1] !== undefined) {
132
+ inherited.push(...splitDisallowedToolsList(match[1]));
133
+ continue;
134
+ }
135
+ for (i++; i < args.length; i++) {
136
+ const value = args[i];
137
+ if (value === undefined || value.startsWith("--")) break;
138
+ inherited.push(...splitDisallowedToolsList(value));
139
+ }
140
+ i--;
141
+ }
142
+ return { kept, inherited };
143
+ }
144
+
145
+ function splitDisallowedToolsList(value: string): string[] {
146
+ return value.split(",").map((name) => name.trim()).filter(Boolean);
147
+ }
148
+
149
+ /** Appends the profile's #2453 denylist to the launch args, merging with any inherited
150
+ * --disallowedTools entries into ONE canonical trailing occurrence (union, never clobber).
151
+ * No-op (args returned as-is) when the profile resolves no denylist. The single launch seam
152
+ * for this trim is ClaudeAdapter.buildSpawnArgs.
153
+ * Emitted as ONE `--disallowedTools=a,b,...` token: the space-separated variadic form would
154
+ * consume every following non-flag token — buildSpawnArgs pushes --model only when set and
155
+ * then the seeded initial-prompt positional, so a trailing variadic on an unpinned-model
156
+ * launch would eat the prompt as a deny value (silent no-prompt launch). The `=` form
157
+ * terminates the flag so following positionals survive. */
158
+ export function claudeProfileDisallowedToolsArgs(args: string[], config: Pick<RunnerSpawnConfig, "harnessSubagents" | "profile" | "agentProfile">): string[] {
159
+ // #3024 — a profile's switched-off web tools are hidden whatever its denylist says.
160
+ const deny = [...(harnessSubagentsEnabled(config) ? [] : ["Agent", "Task", "TeamCreate", "TeamDelete", "SendMessage"]), ...claudeProfileDisallowedTools(config), ...claudeHarnessTrim(config).deny, ...claudeDisallowedWebTools(config)];
161
+ if (!deny.length) return args;
162
+ const { kept, inherited } = partitionClaudeDisallowedToolsArgs(args);
163
+ const merged = [...new Set([...inherited, ...deny])];
164
+ return [...kept, `--disallowedTools=${merged.join(",")}`];
165
+ }
@@ -0,0 +1,193 @@
1
+ import { open, stat } from "node:fs/promises";
2
+ import { LatestTurnStepAccumulator, type TurnStep } from "./claude-transcript";
3
+
4
+ const FINGERPRINT_BYTES = 4096;
5
+
6
+ export interface ClaudeTranscriptFileIdentity {
7
+ dev: number;
8
+ ino: number;
9
+ birthtimeMs: number;
10
+ }
11
+
12
+ interface TailFingerprint {
13
+ offset: number;
14
+ bytes: Buffer;
15
+ }
16
+
17
+ interface TranscriptTailFs {
18
+ stat: typeof stat;
19
+ open: typeof open;
20
+ }
21
+
22
+ export interface IncrementalTranscriptPollResult {
23
+ available: boolean;
24
+ changed: boolean;
25
+ reset: boolean;
26
+ steps: TurnStep[];
27
+ transcriptLooksComplete: boolean;
28
+ /** Claude's native prompt id for the latest turn, when known. */
29
+ promptId?: string;
30
+ /** Real user entries appended since this tail's previous poll. */
31
+ userPrompts: string[];
32
+ /** Entry timestamps corresponding to userPrompts, when Claude supplied them. */
33
+ userPromptOccurredAts: Array<number | undefined>;
34
+ }
35
+
36
+ export class IncrementalClaudeTranscriptTail {
37
+ private offset = 0;
38
+ private pending = Buffer.alloc(0);
39
+ private identity: ClaudeTranscriptFileIdentity | undefined;
40
+ private fingerprint: TailFingerprint | undefined;
41
+ private newUserPrompts: string[] = [];
42
+ private newUserPromptOccurredAts: Array<number | undefined> = [];
43
+ private readonly accumulator = new LatestTurnStepAccumulator();
44
+
45
+ constructor(private readonly fs: TranscriptTailFs = { stat, open }) {}
46
+
47
+ /** Start watching at the current EOF without parsing an old, potentially huge session. */
48
+ async seekToEnd(path: string): Promise<boolean> {
49
+ let fileStat: Awaited<ReturnType<typeof stat>>;
50
+ try { fileStat = await this.fs.stat(path); } catch { return false; }
51
+ const length = Math.min(FINGERPRINT_BYTES, fileStat.size);
52
+ const buffer = Buffer.alloc(length);
53
+ let handle: Awaited<ReturnType<typeof open>>;
54
+ try { handle = await this.fs.open(path, "r"); } catch { return false; }
55
+ try {
56
+ const read = await handle.read(buffer, 0, length, fileStat.size - length);
57
+ if (read.bytesRead !== length) return false;
58
+ } finally { await handle.close(); }
59
+ this.offset = fileStat.size;
60
+ this.pending = Buffer.alloc(0);
61
+ this.identity = { dev: fileStat.dev, ino: fileStat.ino, birthtimeMs: fileStat.birthtimeMs };
62
+ this.fingerprint = { offset: fileStat.size - length, bytes: buffer };
63
+ this.accumulator.reset();
64
+ this.newUserPrompts = [];
65
+ this.newUserPromptOccurredAts = [];
66
+ return true;
67
+ }
68
+
69
+ async poll(path: string): Promise<IncrementalTranscriptPollResult> {
70
+ this.newUserPrompts = [];
71
+ this.newUserPromptOccurredAts = [];
72
+ let fileStat: Awaited<ReturnType<typeof stat>>;
73
+ try {
74
+ fileStat = await this.fs.stat(path);
75
+ } catch {
76
+ return this.result({ available: false, changed: false, reset: false });
77
+ }
78
+
79
+ const nextIdentity = { dev: fileStat.dev, ino: fileStat.ino, birthtimeMs: fileStat.birthtimeMs };
80
+ const replaced = this.identity !== undefined && !sameIdentity(this.identity, nextIdentity);
81
+ const truncated = this.offset > fileStat.size;
82
+ let reset = replaced || truncated;
83
+ if (!reset && this.identity && this.fingerprint && fileStat.size >= this.offset) {
84
+ reset = !(await this.fingerprintMatches(path));
85
+ }
86
+ if (reset) this.reset(nextIdentity);
87
+ else if (!this.identity) this.identity = nextIdentity;
88
+
89
+ if (this.offset === fileStat.size) {
90
+ return this.result({ available: true, changed: false, reset });
91
+ }
92
+
93
+ const length = fileStat.size - this.offset;
94
+ const buffer = Buffer.alloc(length);
95
+ let bytesRead = 0;
96
+ // A transcript can be rotated after stat() but before open(). That is ordinary lifecycle
97
+ // churn, not a runner-fatal error: report this poll as unavailable and retry next interval.
98
+ let handle: Awaited<ReturnType<typeof open>>;
99
+ try {
100
+ handle = await this.fs.open(path, "r");
101
+ } catch {
102
+ return this.result({ available: false, changed: false, reset });
103
+ }
104
+ try {
105
+ const read = await handle.read(buffer, 0, length, this.offset);
106
+ bytesRead = read.bytesRead;
107
+ } finally {
108
+ await handle.close();
109
+ }
110
+
111
+ this.offset += bytesRead;
112
+ const delta = buffer.subarray(0, bytesRead);
113
+ this.updateFingerprint(delta);
114
+ const changed = bytesRead > 0 && this.consume(delta);
115
+ // A reset replays the new file from byte zero. Its history predates this
116
+ // submission probe, even when an old user entry happens to match this prompt.
117
+ if (reset) {
118
+ this.newUserPrompts = [];
119
+ this.newUserPromptOccurredAts = [];
120
+ }
121
+ return this.result({ available: true, changed, reset });
122
+ }
123
+
124
+ private reset(identity: ClaudeTranscriptFileIdentity): void {
125
+ this.offset = 0;
126
+ this.pending = Buffer.alloc(0);
127
+ this.fingerprint = undefined;
128
+ this.identity = identity;
129
+ this.accumulator.reset();
130
+ this.newUserPrompts = [];
131
+ this.newUserPromptOccurredAts = [];
132
+ }
133
+
134
+ private async fingerprintMatches(path: string): Promise<boolean> {
135
+ if (!this.fingerprint) return true;
136
+ const buffer = Buffer.alloc(this.fingerprint.bytes.length);
137
+ let handle: Awaited<ReturnType<typeof open>>;
138
+ try {
139
+ handle = await this.fs.open(path, "r");
140
+ } catch {
141
+ return false;
142
+ }
143
+ try {
144
+ const read = await handle.read(buffer, 0, buffer.length, this.fingerprint.offset);
145
+ return read.bytesRead === buffer.length && buffer.equals(this.fingerprint.bytes);
146
+ } finally {
147
+ await handle.close();
148
+ }
149
+ }
150
+
151
+ private updateFingerprint(delta: Buffer): void {
152
+ if (!delta.length) return;
153
+ const previous = this.fingerprint?.bytes ?? Buffer.alloc(0);
154
+ const combined = previous.length ? Buffer.concat([previous, delta]) : delta;
155
+ const bytes = Buffer.from(combined.subarray(Math.max(0, combined.length - FINGERPRINT_BYTES)));
156
+ this.fingerprint = { offset: this.offset - bytes.length, bytes };
157
+ }
158
+
159
+ private consume(delta: Buffer): boolean {
160
+ if (!delta.length) return false;
161
+ const combined = this.pending.length ? Buffer.concat([this.pending, delta]) : delta;
162
+ const lastNewline = combined.lastIndexOf(0x0a);
163
+ if (lastNewline === -1) {
164
+ this.pending = Buffer.from(combined);
165
+ return false;
166
+ }
167
+
168
+ const complete = combined.subarray(0, lastNewline + 1);
169
+ this.pending = Buffer.from(combined.subarray(lastNewline + 1));
170
+ this.accumulator.appendJsonl(complete.toString("utf8"));
171
+ const prompts = this.accumulator.drainUserPrompts();
172
+ this.newUserPrompts = prompts.map((prompt) => prompt.text);
173
+ this.newUserPromptOccurredAts = prompts.map((prompt) => prompt.occurredAt);
174
+ return true;
175
+ }
176
+
177
+ private result(input: { available: boolean; changed: boolean; reset: boolean }): IncrementalTranscriptPollResult {
178
+ return {
179
+ ...input,
180
+ steps: this.accumulator.steps,
181
+ transcriptLooksComplete: this.accumulator.transcriptLooksComplete,
182
+ ...(this.accumulator.promptId ? { promptId: this.accumulator.promptId } : {}),
183
+ userPrompts: this.newUserPrompts,
184
+ userPromptOccurredAts: this.newUserPromptOccurredAts,
185
+ };
186
+ }
187
+ }
188
+
189
+ function sameIdentity(a: ClaudeTranscriptFileIdentity, b: ClaudeTranscriptFileIdentity): boolean {
190
+ return a.dev === b.dev && a.ino === b.ino && a.birthtimeMs === b.birthtimeMs;
191
+ }
192
+
193
+ export const sameClaudeTranscriptFileIdentityForTests = sameIdentity;