@cat-factory/executor-harness 1.80.0 → 1.84.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README.md +1 -0
  2. package/dist/agent-capabilities.d.ts +130 -0
  3. package/dist/agent-runner.d.ts +114 -0
  4. package/dist/agent-runner.js +50 -32
  5. package/dist/agent-shared.d.ts +18 -0
  6. package/dist/agent.d.ts +66 -0
  7. package/dist/bootstrap-mode.d.ts +20 -0
  8. package/dist/captured-command.d.ts +58 -0
  9. package/dist/claude-call-aggregator.d.ts +164 -0
  10. package/dist/claude-call-aggregator.js +123 -17
  11. package/dist/claude-stream.d.ts +56 -0
  12. package/dist/coding-agent.d.ts +252 -0
  13. package/dist/coding-agent.js +69 -50
  14. package/dist/dependency-install.d.ts +111 -0
  15. package/dist/effort.d.ts +19 -0
  16. package/dist/embed.d.ts +4 -0
  17. package/dist/failure.d.ts +42 -0
  18. package/dist/follow-ups.d.ts +28 -0
  19. package/dist/frontend-infra.d.ts +25 -0
  20. package/dist/fs-utils.d.ts +2 -0
  21. package/dist/git.d.ts +394 -0
  22. package/dist/host-markdown.d.ts +28 -0
  23. package/dist/inline.d.ts +10 -0
  24. package/dist/job.d.ts +666 -0
  25. package/dist/logger.d.ts +16 -0
  26. package/dist/onboarding-preseed.d.ts +24 -0
  27. package/dist/package-registries.d.ts +32 -0
  28. package/dist/pi-workspace.d.ts +194 -0
  29. package/dist/pi.d.ts +475 -0
  30. package/dist/pr-description.d.ts +85 -0
  31. package/dist/pr-template.d.ts +101 -0
  32. package/dist/process-exit.d.ts +7 -0
  33. package/dist/process.d.ts +19 -0
  34. package/dist/progress-guard.d.ts +88 -0
  35. package/dist/progress.d.ts +87 -0
  36. package/dist/redact.d.ts +31 -0
  37. package/dist/reproduction-proof.d.ts +224 -0
  38. package/dist/runner.d.ts +282 -0
  39. package/dist/server.d.ts +3 -0
  40. package/dist/structured-output.d.ts +75 -0
  41. package/dist/subagents.d.ts +88 -0
  42. package/dist/transcript-retention.d.ts +21 -0
  43. package/dist/validation-checks.d.ts +159 -0
  44. package/dist/vcs-api.d.ts +73 -0
  45. package/dist/version.d.ts +2 -0
  46. package/package.json +9 -5
  47. package/src/agent-runner.ts +54 -29
  48. package/src/claude-call-aggregator.ts +181 -32
  49. package/src/coding-agent.ts +80 -49
@@ -0,0 +1,73 @@
1
+ import type { PrSpec } from './job.js';
2
+ /**
3
+ * Classify a PR/MR-open REST failure by its HTTP status into an actionable remedy, else
4
+ * undefined (an unmapped status keeps just the raw `Failed to open … (HTTP n)` line). Like
5
+ * {@link describeGitFailure} this only APPENDS a cause + fix — the raw status line is
6
+ * load-bearing detail and stays. `provider` tailors the scope/permission wording (GitHub App
7
+ * Pull-requests permission / `repo` PAT scope vs GitLab `api` scope) and the noun (pull
8
+ * request vs merge request). Pure, so it is unit-tested per status.
9
+ */
10
+ export declare function describePrOpenFailure(status: number, provider: 'github' | 'gitlab'): string | undefined;
11
+ export interface OpenPullRequestOptions {
12
+ owner: string;
13
+ name: string;
14
+ ghToken: string;
15
+ head: string;
16
+ base: string;
17
+ pr: PrSpec;
18
+ apiBase?: string;
19
+ /**
20
+ * The repo's clone URL. Used (when {@link provider} is absent) to detect the provider and,
21
+ * for GitLab, to derive the REST base + project path from its host — so the harness opens a
22
+ * GitLab **merge request** rather than POSTing to GitHub's pulls API. Absent ⇒ GitHub.
23
+ */
24
+ cloneUrl?: string;
25
+ /**
26
+ * The VCS provider, when the dispatcher knows it (the server derives it from the configured
27
+ * source-control backend and sets `repo.provider`). AUTHORITATIVE — it overrides host
28
+ * inference — so a self-managed GitLab on an arbitrarily-named host (e.g. `git.acme.com`,
29
+ * which {@link inferVcsProvider} can't recognise) still opens a merge request instead of
30
+ * being misrouted to GitHub's API. Absent ⇒ inferred from {@link cloneUrl}'s host.
31
+ */
32
+ provider?: 'github' | 'gitlab';
33
+ /**
34
+ * When the PR/MR for {@link head} ALREADY exists (a resumed run pushing onto a branch whose PR
35
+ * is open), replace its title and description with {@link pr} instead of leaving them alone.
36
+ *
37
+ * Set ONLY when {@link pr} carries the agent's own reviewer briefing. A resumed run is exactly
38
+ * the case that matters — eviction + re-dispatch, a ralph iteration, a retry — and without this
39
+ * the agent writes a briefing the platform reads, scrubs, caps and then silently drops. It must
40
+ * stay opt-in, though: refreshing from the GENERIC dispatch-time fallback would overwrite a
41
+ * description a human (or an earlier, better-informed run) had already written.
42
+ *
43
+ * The engine's managed verification-report region is carried across the rewrite by
44
+ * {@link preserveManagedSection}, and the update is best-effort — a failed refresh keeps the
45
+ * run's real outcome, which is the pushed work.
46
+ */
47
+ refreshExisting?: boolean;
48
+ signal?: AbortSignal;
49
+ }
50
+ /**
51
+ * The VCS host a clone URL points at. The harness is otherwise provider-agnostic (its git
52
+ * auth is a host-neutral GIT_ASKPASS credential), but the "open the PR/MR" REST call is not:
53
+ * GitHub and GitLab have different endpoints, so infer which to call from the host. GitHub is
54
+ * the default; a host of `gitlab.com` or one in the `gitlab.*` / `*.gitlab.*` family (covering
55
+ * self-managed instances named that way) is treated as GitLab.
56
+ */
57
+ export declare function inferVcsProvider(cloneUrl: string): 'github' | 'gitlab';
58
+ /** The GitLab REST v4 base for a clone URL's host, e.g. `https://gitlab.com/api/v4`. */
59
+ export declare function gitlabApiBaseFromCloneUrl(cloneUrl: string): string;
60
+ /**
61
+ * The URL-encoded GitLab project path from a clone URL — the full namespace path (so subgroups
62
+ * survive), with the trailing `.git` stripped, e.g.
63
+ * `https://gitlab.com/group/sub/proj.git` → `group%2Fsub%2Fproj`.
64
+ */
65
+ export declare function gitlabProjectPath(cloneUrl: string): string;
66
+ /**
67
+ * Open a PR (GitHub) or merge request (GitLab) for the pushed branch; returns its web URL.
68
+ * The provider is chosen from the EXPLICIT `opts.provider` when the dispatcher set it,
69
+ * falling back to host inference from the clone URL only when it didn't — so a self-managed
70
+ * GitLab whose host isn't named `gitlab.*` still opens an MR instead of being misrouted to
71
+ * GitHub's API. The GitHub path is unchanged.
72
+ */
73
+ export declare function openPullRequest(opts: OpenPullRequestOptions): Promise<string | null>;
@@ -0,0 +1,2 @@
1
+ /** The running harness version, or undefined when it cannot be determined. */
2
+ export declare const HARNESS_VERSION: string | undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/executor-harness",
3
- "version": "1.80.0",
3
+ "version": "1.84.0",
4
4
  "description": "Container payload: a thin TypeScript wrapper that runs the Pi coding agent against a cloned repo and opens a PR. Runs in the Cloudflare Container (and, in local native mode, as a host process); carries no secrets.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -15,7 +15,11 @@
15
15
  "main": "./dist/server.js",
16
16
  "exports": {
17
17
  ".": "./dist/server.js",
18
- "./embed": "./src/embed.ts"
18
+ "./embed": "./src/embed.ts",
19
+ "./claude-call-aggregator": {
20
+ "types": "./dist/claude-call-aggregator.d.ts",
21
+ "default": "./dist/claude-call-aggregator.js"
22
+ }
19
23
  },
20
24
  "publishConfig": {
21
25
  "access": "public"
@@ -26,9 +30,9 @@
26
30
  "hono": "^4.12.32",
27
31
  "typescript": "7.0.2",
28
32
  "vitest": "^4.1.10",
29
- "@cat-factory/server": "0.183.0",
30
- "@cat-factory/kernel": "0.197.0",
31
- "@cat-factory/spend": "0.12.127"
33
+ "@cat-factory/kernel": "0.202.0",
34
+ "@cat-factory/spend": "0.12.133",
35
+ "@cat-factory/server": "0.187.0"
32
36
  },
33
37
  "scripts": {
34
38
  "build": "tsc -p tsconfig.json",
@@ -488,6 +488,56 @@ async function setUpClaudeMcp(
488
488
  }
489
489
  }
490
490
 
491
+ /**
492
+ * No-progress guard on the CLI's own tool stream — the claude-code analogue of runPi's guard,
493
+ * which cannot see the CLI's internal turns. The caller remembers each `tool_use` id's name off
494
+ * the assistant turn (`rememberTool`) and hands the following user turn's content to `feedGuard`,
495
+ * which pairs each `tool_result`'s `is_error` with that name. The FIRST reason trips it: the
496
+ * diagnostic is recorded (readable via `reason()`, which the catch surfaces over the generic abort
497
+ * message) and `guardAbort` fires — folded into streamCli's signal so a tripped guard kills the CLI
498
+ * the same way the external watchdog does. Disabled when the caller supplies no limits (only the
499
+ * external watchdog then bounds the run).
500
+ *
501
+ * Split out of {@link runClaudeCode} for the per-function line budget.
502
+ */
503
+ function createClaudeProgressGuard(opts: SubscriptionRunOptions): {
504
+ rememberTool: (id: string, name: string) => void
505
+ feedGuard: (content: unknown[]) => void
506
+ guardAbort: AbortController
507
+ reason: () => string | undefined
508
+ } {
509
+ const guard = opts.guardLimits
510
+ ? new ProgressGuard(opts.guardLimits, opts.expectsEdits ?? true)
511
+ : undefined
512
+ const toolNames = new Map<string, string>()
513
+ const guardAbort = new AbortController()
514
+ let guardReason: string | undefined
515
+
516
+ const feedGuard = (content: unknown[]): void => {
517
+ if (!guard || guardReason) return
518
+ for (const block of content) {
519
+ if (!isObject(block) || block.type !== 'tool_result') continue
520
+ const id = typeof block.tool_use_id === 'string' ? block.tool_use_id : undefined
521
+ const name = id ? toolNames.get(id) : undefined
522
+ if (id) toolNames.delete(id)
523
+ if (!name) continue
524
+ const reason = guard.observeSignal({ name, isError: block.is_error === true })
525
+ if (reason) {
526
+ guardReason = reason
527
+ guardAbort.abort()
528
+ return
529
+ }
530
+ }
531
+ }
532
+
533
+ return {
534
+ rememberTool: (id, name) => toolNames.set(id, name),
535
+ feedGuard,
536
+ guardAbort,
537
+ reason: () => guardReason,
538
+ }
539
+ }
540
+
491
541
  export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRunOutcome> {
492
542
  const stats: PiRunStats = { toolCalls: 0, assistantChars: 0 }
493
543
  let summary = ''
@@ -576,34 +626,8 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
576
626
  // `tool_use` id to feed the guard a {name,isError} signal. A tripped guard aborts the CLI via
577
627
  // `guardAbort` (folded into streamCli's signal below) and the run then fails with its
578
628
  // diagnostic. Disabled when the caller supplies no limits (only the external watchdog bounds it).
579
- const guard = opts.guardLimits
580
- ? new ProgressGuard(opts.guardLimits, opts.expectsEdits ?? true)
581
- : undefined
582
- const toolNames = new Map<string, string>()
583
- const guardAbort = new AbortController()
584
- let guardReason: string | undefined
585
-
586
- // Feed a user turn's settled tool calls to the guard, pairing each `tool_result`'s `is_error`
587
- // with the name captured for its `tool_use` id on the assistant turn. The FIRST reason trips
588
- // it: record the diagnostic and abort the CLI (streamCli's close handler rejects; the catch
589
- // below surfaces `guardReason` over the generic abort message). A standalone closure so the
590
- // per-block loop doesn't nest onEvent past the readable-depth limit.
591
- const feedGuard = (content: unknown[]): void => {
592
- if (!guard || guardReason) return
593
- for (const block of content) {
594
- if (!isObject(block) || block.type !== 'tool_result') continue
595
- const id = typeof block.tool_use_id === 'string' ? block.tool_use_id : undefined
596
- const name = id ? toolNames.get(id) : undefined
597
- if (id) toolNames.delete(id)
598
- if (!name) continue
599
- const reason = guard.observeSignal({ name, isError: block.is_error === true })
600
- if (reason) {
601
- guardReason = reason
602
- guardAbort.abort()
603
- return
604
- }
605
- }
606
- }
629
+ const progressGuard = createClaudeProgressGuard(opts)
630
+ const { rememberTool, feedGuard, guardAbort } = progressGuard
607
631
 
608
632
  const onEvent = (event: Record<string, unknown>, meta?: { final?: boolean }): void => {
609
633
  const type = event.type
@@ -625,7 +649,7 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
625
649
  // Remember each call's name against its id so the guard can pair it with the
626
650
  // `is_error` its `tool_result` carries on the next `user` turn.
627
651
  if (typeof block.id === 'string' && typeof block.name === 'string') {
628
- toolNames.set(block.id, block.name)
652
+ rememberTool(block.id, block.name)
629
653
  }
630
654
  if (block.name === 'TodoWrite') {
631
655
  const progress = todosToProgress((block.input as Record<string, unknown>)?.todos)
@@ -739,6 +763,7 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
739
763
  // report is appended after them when the CLI managed to emit one before it was killed, which
740
764
  // is uncommon but is the same evidence a bad exit now carries — a guard trip is no reason to
741
765
  // discard it.
766
+ const guardReason = progressGuard.reason()
742
767
  if (guardReason) {
743
768
  const tail = (err as { stderrTail?: string } | undefined)?.stderrTail
744
769
  const report = capReport(redact(terminalReport, secrets).trim())
@@ -12,6 +12,19 @@ import type { HarnessCallMetric } from './pi.js'
12
12
  // This aggregator folds every envelope sharing a `message.id` back into the one call it belongs to,
13
13
  // and buffers that call's tool_result turns so the reconstructed prompt chain keeps the shape the
14
14
  // model was actually sent: one assistant turn holding all its blocks, then the results.
15
+ //
16
+ // TWO CONSUMERS, and a change here reaches both. The container/host harness drives it from a
17
+ // coding job's stdout (`agent-runner.ts`), and the BACKEND drives it from the host `claude` an
18
+ // inline step runs on (`runtimes/local/src/harnessInline.ts`, importing this module through the
19
+ // package's `./claude-call-aggregator` subpath). Both parse the SAME `stream-json`, and the second
20
+ // used to carry its own lesser fold — which is how the inline path came to report one lumped call
21
+ // per step while the container path reported every turn. Keep this the only implementation; it is
22
+ // also the one place the per-block over-count above is fixed.
23
+ //
24
+ // The second consumer is why the transcript is BOUNDED and body assembly is OPTIONAL (see
25
+ // {@link MAX_TRANSCRIPT_CHARS} and `bodies`). Inside a container the reconstruction is one job's
26
+ // worth of memory in a box sized for it; in the backend it runs per concurrent inline step, in the
27
+ // orchestrator process, on precisely the long tool loops worth diagnosing.
15
28
 
16
29
  /** One model call, assembled from every stream envelope that carried a piece of it. */
17
30
  export interface AggregatedClaudeCall {
@@ -134,6 +147,128 @@ interface TranscriptTurn {
134
147
  content: unknown
135
148
  }
136
149
 
150
+ /**
151
+ * How much reconstructed transcript ONE conversation may retain.
152
+ *
153
+ * The stream feeds this without limit — a tool loop that reads large files grows the history by
154
+ * every one of them — and the reconstruction is held in the driver's own process. In the container
155
+ * that is a box sized for one job; in the BACKEND it is the orchestrator, where `streamCli` already
156
+ * refuses to retain the raw stream for exactly this reason (`harnessInline.ts` →
157
+ * `OUTPUT_TAIL_RETAIN_CHARS`: "a stalled tool-using run would otherwise park hundreds of MB in the
158
+ * orchestrator process — precisely on the runs worth diagnosing").
159
+ *
160
+ * 512 KiB because that is `LlmObservabilityService.MAX_BODY_CHARS`, the point past which the store
161
+ * truncates a body anyway: retaining more can only ever be thrown away. Deliberately NOT a
162
+ * per-deployment knob — it bounds a memory fault, and a number an operator can raise is one an
163
+ * operator can raise until the process dies.
164
+ */
165
+ export const MAX_TRANSCRIPT_CHARS = 512 * 1024
166
+
167
+ /**
168
+ * The role a turn carries when it is not a turn at all, but the note saying what stopped being
169
+ * retained. A distinct namespaced role rather than `system`, so nothing downstream can read it as a
170
+ * message that was actually sent — the same reason `seed` exists.
171
+ */
172
+ const ELIDED_ROLE = 'cat-factory:elided'
173
+
174
+ /** The growing request transcript behind a conversation's `promptText`. */
175
+ interface Transcript {
176
+ /** The history as of NOW: what the call starting at this moment was sent. */
177
+ snapshot(): { text: string; messageCount: number }
178
+ /** Add a turn the conversation has now completed. */
179
+ append(turn: TranscriptTurn): void
180
+ }
181
+
182
+ /**
183
+ * Retain the transcript up to {@link MAX_TRANSCRIPT_CHARS} and then STOP, stating what it stopped
184
+ * retaining rather than silently ending mid-conversation.
185
+ *
186
+ * Freezing the tail (rather than evicting the head) keeps the seed and the early history — the
187
+ * task, and the turns that explain what the loop is doing — and keeps each call's `promptText` a
188
+ * stable PREFIX plus a changing note, so the backend's chain delta-compresses right up to the bound
189
+ * and only then degrades to storing the (now capped) array. Evicting the head would drop the task
190
+ * itself and break the prefix property from the first eviction on.
191
+ *
192
+ * The seed is never dropped: it is what the CALLER sent, so it is bounded by the caller's own
193
+ * prompt rather than by the stream, and it is the half a reader cannot reconstruct from anything
194
+ * else.
195
+ */
196
+ function createBoundedTranscript(
197
+ seed: TranscriptTurn[],
198
+ secrets: string[],
199
+ maxChars: number,
200
+ ): Transcript {
201
+ const turns: TranscriptTurn[] = [...seed]
202
+ const sizeOf = (turn: TranscriptTurn): number => {
203
+ try {
204
+ return JSON.stringify(turn)?.length ?? 0
205
+ } catch {
206
+ // An un-serialisable turn cannot be retained at all, so charge it nothing and let the
207
+ // append below drop it on its own terms.
208
+ return Number.POSITIVE_INFINITY
209
+ }
210
+ }
211
+ let retained = turns.reduce((n, turn) => n + sizeOf(turn), 0)
212
+ const dropped = { turns: 0, chars: 0 }
213
+ return {
214
+ append(turn) {
215
+ const size = sizeOf(turn)
216
+ if (retained + size > maxChars) {
217
+ dropped.turns += 1
218
+ dropped.chars += Number.isFinite(size) ? size : 0
219
+ return
220
+ }
221
+ turns.push(turn)
222
+ retained += size
223
+ },
224
+ snapshot() {
225
+ const encoded = dropped.turns
226
+ ? [
227
+ ...turns,
228
+ {
229
+ role: ELIDED_ROLE,
230
+ content:
231
+ `${dropped.turns} later turn(s), ${dropped.chars} chars, were not retained: ` +
232
+ `this conversation reached the ${maxChars}-char reconstruction bound`,
233
+ },
234
+ ]
235
+ : turns
236
+ return {
237
+ text: redactBody(safeSerialise(encoded), secrets),
238
+ messageCount: encoded.length,
239
+ }
240
+ },
241
+ }
242
+ }
243
+
244
+ /**
245
+ * Count the turns and assemble NO bodies — for a driver that has nowhere to put them (the backend
246
+ * with `LLM_RECORD_PROMPTS` off, where the store drops every body it is handed).
247
+ *
248
+ * `messageCount` stays real, because it is a COUNT rather than a body and every consumer of the
249
+ * metric wants it. The point is not to omit data the gate would keep; it is that serialising a
250
+ * transcript the gate is about to drop is pure cost, and the whole reason bodies travel to the
251
+ * recorder as thunks (`CLAUDE.md` → "Telemetry & agent-context observability").
252
+ */
253
+ function createCountingTranscript(seed: TranscriptTurn[]): Transcript {
254
+ let messageCount = seed.length
255
+ return {
256
+ append() {
257
+ messageCount += 1
258
+ },
259
+ snapshot: () => ({ text: '', messageCount }),
260
+ }
261
+ }
262
+
263
+ /** Serialise a transcript for the store, never throwing into the stream that produced it. */
264
+ function safeSerialise(turns: TranscriptTurn[]): string {
265
+ try {
266
+ return JSON.stringify(turns) ?? ''
267
+ } catch {
268
+ return ''
269
+ }
270
+ }
271
+
137
272
  /** The per-call telemetry the Claude Code stream yields, assembled behind one small surface. */
138
273
  export interface ClaudeStreamTelemetry {
139
274
  /** Fold an `assistant` envelope in (parent-loop turns only — see {@link isSubagentEvent}). */
@@ -155,20 +290,26 @@ export interface ClaudeStreamTelemetry {
155
290
  * was not sent. A subagent's conversation seeds EMPTY — its prompt was minted by the CLI and never
156
291
  * crosses this stream — which is also why its first call carries `messageCount: 0` (the backend's
157
292
  * `latestChainTip` skips those on purpose: there is no re-sendable chain to delta against).
158
- * Bodies are credential-scrubbed; they can echo the leased token.
293
+ * Bodies are credential-scrubbed; they can echo the leased token — and assembled at all only when
294
+ * {@link ClaudeStreamTelemetryOptions.bodies} says a driver has somewhere to put them. The
295
+ * transcript is bounded either way ({@link MAX_TRANSCRIPT_CHARS}).
159
296
  *
160
297
  * Deliberately does NOT touch {@link PiRunStats}: the run's tool/output counters describe whether
161
298
  * the agent ACTED at all (`agentNeverActed`), which is true of a subagent's turns whichever channel
162
299
  * ends up owning their telemetry rows. The caller accumulates them off the raw stream instead.
163
300
  */
164
- export function createClaudeStreamTelemetry(opts: {
165
- seed: TranscriptTurn[]
166
- secrets: string[]
167
- publish: (metric: HarnessCallMetric) => void
168
- }): ClaudeStreamTelemetry {
169
- const messages: TranscriptTurn[] = [...opts.seed]
170
- let callPrompt = ''
171
- let callMessageCount = 0
301
+ export function createClaudeStreamTelemetry(
302
+ opts: ClaudeStreamTelemetryOptions,
303
+ ): ClaudeStreamTelemetry {
304
+ const bodies = opts.bodies ?? true
305
+ const transcript = bodies
306
+ ? createBoundedTranscript(
307
+ opts.seed,
308
+ opts.secrets,
309
+ opts.maxTranscriptChars ?? MAX_TRANSCRIPT_CHARS,
310
+ )
311
+ : createCountingTranscript(opts.seed)
312
+ let sent = { text: '', messageCount: 0 }
172
313
 
173
314
  // The aggregator IS the surface: the transcript and metric work happens in its callbacks, so
174
315
  // there is nothing to wrap it in.
@@ -177,16 +318,15 @@ export function createClaudeStreamTelemetry(opts: {
177
318
  // produced the response, and later envelopes of the same call must not see the turns it
178
319
  // went on to add.
179
320
  onCallStart: () => {
180
- callPrompt = redactBody(JSON.stringify(messages), opts.secrets)
181
- callMessageCount = messages.length
321
+ sent = transcript.snapshot()
182
322
  },
183
323
  onCall: (call) => {
184
324
  opts.publish({
185
325
  ...(call.model ? { model: call.model } : {}),
186
- promptText: callPrompt,
187
- messageCount: callMessageCount,
188
- responseText: redactBody(call.text, opts.secrets),
189
- reasoningText: redactBody(call.reasoning, opts.secrets),
326
+ promptText: sent.text,
327
+ messageCount: sent.messageCount,
328
+ responseText: bodies ? redactBody(call.text, opts.secrets) : '',
329
+ reasoningText: bodies ? redactBody(call.reasoning, opts.secrets) : '',
190
330
  inputTokens: call.inputTokens,
191
331
  cacheReadTokens: call.cacheReadTokens,
192
332
  cacheWriteTokens: call.cacheWriteTokens,
@@ -195,12 +335,30 @@ export function createClaudeStreamTelemetry(opts: {
195
335
  })
196
336
  // Appended only now, so each call's prompt stays a strict prefix of the next and the
197
337
  // backend's telemetry chain delta-compresses cleanly.
198
- messages.push({ role: 'assistant', content: call.content })
199
- for (const result of call.toolResults) messages.push({ role: 'tool', content: result })
338
+ transcript.append({ role: 'assistant', content: call.content })
339
+ for (const result of call.toolResults) transcript.append({ role: 'tool', content: result })
200
340
  },
201
341
  })
202
342
  }
203
343
 
344
+ /** How one conversation's per-call telemetry is assembled. */
345
+ export interface ClaudeStreamTelemetryOptions {
346
+ seed: TranscriptTurn[]
347
+ secrets: string[]
348
+ publish: (metric: HarnessCallMetric) => void
349
+ /**
350
+ * Whether to assemble the prompt/response BODIES at all. Absent ⇒ true (the container harness,
351
+ * whose job result carries them).
352
+ *
353
+ * `false` for a driver whose store will drop them — the backend with `LLM_RECORD_PROMPTS` off —
354
+ * where reconstructing a transcript per call is pure cost. Token counts, `messageCount` and
355
+ * finish reasons are unaffected: only the bodies go.
356
+ */
357
+ bodies?: boolean
358
+ /** Retention bound override (tests). Absent ⇒ {@link MAX_TRANSCRIPT_CHARS}. */
359
+ maxTranscriptChars?: number
360
+ }
361
+
204
362
  /**
205
363
  * The dispatch (`Agent`/`Task` tool_use) id a stream envelope is tagged with, or `undefined` for a
206
364
  * parent-loop turn.
@@ -238,10 +396,7 @@ export function isSubagentEvent(event: Record<string, unknown>): boolean {
238
396
  * one stream: folding them into a single chain is exactly the defect this whole module removes,
239
397
  * one level down.
240
398
  */
241
- function createSubagentStreamTelemetry(opts: {
242
- secrets: string[]
243
- publish: (metric: HarnessCallMetric) => void
244
- }): {
399
+ function createSubagentStreamTelemetry(opts: Omit<ClaudeStreamTelemetryOptions, 'seed'>): {
245
400
  onAssistant(dispatchId: string, message: Record<string, unknown>): void
246
401
  onToolResult(dispatchId: string, content: unknown[]): void
247
402
  flush(): void
@@ -251,11 +406,8 @@ function createSubagentStreamTelemetry(opts: {
251
406
  let telemetry = perDispatch.get(dispatchId)
252
407
  if (!telemetry) {
253
408
  // Seeded EMPTY: the CLI minted this subagent's prompt and it never crossed the stream.
254
- telemetry = createClaudeStreamTelemetry({
255
- seed: [],
256
- secrets: opts.secrets,
257
- publish: opts.publish,
258
- })
409
+ // Each dispatch gets its OWN retention bound, since each is its own conversation.
410
+ telemetry = createClaudeStreamTelemetry({ ...opts, seed: [] })
259
411
  perDispatch.set(dispatchId, telemetry)
260
412
  }
261
413
  return telemetry
@@ -301,12 +453,9 @@ export interface ClaudeRunTelemetry {
301
453
  * instead, on per-dispatch transcripts of their own. Dropping them in that case would leave the run
302
454
  * billed by neither channel, and an under-count reads as a cheap run rather than as an error.
303
455
  */
304
- export function createClaudeRunTelemetry(opts: {
305
- seed: TranscriptTurn[]
306
- secrets: string[]
307
- watcherOwnsSubagents: boolean
308
- publish: (metric: HarnessCallMetric) => void
309
- }): ClaudeRunTelemetry {
456
+ export function createClaudeRunTelemetry(
457
+ opts: ClaudeStreamTelemetryOptions & { watcherOwnsSubagents: boolean },
458
+ ): ClaudeRunTelemetry {
310
459
  const parent = createClaudeStreamTelemetry(opts)
311
460
  const subagents = opts.watcherOwnsSubagents ? undefined : createSubagentStreamTelemetry(opts)
312
461
  let sawSubagentTurn = false
@@ -260,6 +260,75 @@ function followUpPollIntervalMs(): number {
260
260
  * retry resumes on them. Returns the run's summary/stats, whether it pushed, and
261
261
  * whether it resumed; callers decide what to do after a push (open a PR, or nothing).
262
262
  */
263
+ /**
264
+ * The work-branch push machinery for one coding run: a single coalesced push plus the periodic
265
+ * checkpoint that keeps mid-run commits durable. Split out of {@link runCodingAgent} for the
266
+ * per-function line budget; the caller owns the interval's lifetime (it clears `checkpoint`).
267
+ *
268
+ * Serialize all pushes to the work branch through a single in-flight promise. A checkpoint tick
269
+ * and the final push (or two slow checkpoint ticks) must never run `git push` to the same branch
270
+ * concurrently: overlapping pushes race on the remote ref and can make a push fail with a
271
+ * ref-lock / non-fast-forward error — which, on the FINAL push, would fail the whole run even
272
+ * though the work is committed. `pushWorkOnce` coalesces concurrent callers onto one push and only
273
+ * pushes once the branch has advanced past `baseSha`.
274
+ *
275
+ * Only push once the branch has advanced past its pre-run tip: pushing while it still sits at
276
+ * `baseSha` would create the work branch at the base commit (a zero-diff branch), which a later
277
+ * retry would see via `remoteBranchExists` and treat as resumable work — then fail to open a PR
278
+ * ("no commits between base and head"). So a run that never commits leaves NO branch behind,
279
+ * preserving the clean no-op outcome.
280
+ */
281
+ function createWorkBranchPusher(args: {
282
+ dir: string
283
+ spec: CodingAgentSpec
284
+ baseSha: string
285
+ logger: Logger
286
+ signal: AbortSignal | undefined
287
+ }): {
288
+ pushWorkOnce: () => Promise<void>
289
+ inFlightPush: () => Promise<void> | null
290
+ checkpoint: ReturnType<typeof setInterval>
291
+ } {
292
+ const { dir, spec, baseSha, logger, signal } = args
293
+ let pushInFlight: Promise<void> | null = null
294
+ const pushWorkOnce = (): Promise<void> => {
295
+ if (pushInFlight) return pushInFlight
296
+ pushInFlight = (async () => {
297
+ if (!(await branchHasCommitsSince(dir, baseSha, signal))) return
298
+ await pushBranch(dir, spec.pushBranch, spec.ghToken, signal)
299
+ })().finally(() => {
300
+ pushInFlight = null
301
+ })
302
+ return pushInFlight
303
+ }
304
+ // Read the in-flight push, if any. A function (with an explicit return type) so the
305
+ // value isn't subject to the caller's straight-line narrowing — `pushInFlight` is
306
+ // only ever assigned inside closures, which flow analysis can't observe.
307
+ const inFlightPush = (): Promise<void> | null => pushInFlight
308
+
309
+ // Checkpoint the agent's committed work to the branch periodically so an eviction
310
+ // mid-run doesn't lose it (a retry then resumes from the pushed commits). The
311
+ // agent commits its own work; this only PUSHES already-committed commits, so it
312
+ // never races the agent's staging. Best-effort: a failed checkpoint is skipped.
313
+ // Surface checkpoint-push failures at warn with a running count: a checkpoint losing
314
+ // a race is harmless once, but a steadily-climbing count means mid-run work is NOT
315
+ // being durably checkpointed, so an eviction would lose it — previously invisible at
316
+ // info level. Still best-effort: a failed checkpoint never fails the run.
317
+ let checkpointFailures = 0
318
+ const checkpoint = setInterval(() => {
319
+ pushWorkOnce().catch((err) => {
320
+ checkpointFailures++
321
+ logger.warn('coding-agent: checkpoint push failed', {
322
+ reason: err instanceof Error ? err.message : String(err),
323
+ checkpointFailures,
324
+ })
325
+ })
326
+ }, checkpointIntervalMs())
327
+ checkpoint.unref?.()
328
+
329
+ return { pushWorkOnce, inFlightPush, checkpoint }
330
+ }
331
+
263
332
  export async function runCodingAgent(
264
333
  spec: CodingAgentSpec,
265
334
  opts: RunOptions = {},
@@ -275,55 +344,17 @@ export async function runCodingAgent(
275
344
  // pre-run branch tip. See {@link prepareCodingCheckout} for the resume-safety invariants.
276
345
  const { resumed, baseSha } = await prepareCodingCheckout(dir, spec, logger, opts)
277
346
 
278
- // Serialize all pushes to the work branch through a single in-flight promise.
279
- // A checkpoint tick and the final push (or two slow checkpoint ticks) must never
280
- // run `git push` to the same branch concurrently: overlapping pushes race on the
281
- // remote ref and can make a push fail with a ref-lock / non-fast-forward error —
282
- // which, on the FINAL push, would fail the whole run even though the work is
283
- // committed. `pushWorkOnce` coalesces concurrent callers onto one push and only
284
- // pushes once the branch has advanced past `baseSha` (see below).
285
- //
286
- // Only push once the branch has advanced past its pre-run tip: pushing while it
287
- // still sits at `baseSha` would create the work branch at the base commit (a
288
- // zero-diff branch), which a later retry would see via `remoteBranchExists` and
289
- // treat as resumable work — then fail to open a PR ("no commits between base and
290
- // head"). So a run that never commits leaves NO branch behind, preserving the
291
- // clean no-op outcome.
292
- let pushInFlight: Promise<void> | null = null
293
- const pushWorkOnce = (): Promise<void> => {
294
- if (pushInFlight) return pushInFlight
295
- pushInFlight = (async () => {
296
- if (!(await branchHasCommitsSince(dir, baseSha, signal))) return
297
- await pushBranch(dir, spec.pushBranch, spec.ghToken, signal)
298
- })().finally(() => {
299
- pushInFlight = null
300
- })
301
- return pushInFlight
302
- }
303
- // Read the in-flight push, if any. A function (with an explicit return type) so the
304
- // value isn't subject to the caller's straight-line narrowing — `pushInFlight` is
305
- // only ever assigned inside closures, which flow analysis can't observe.
306
- const inFlightPush = (): Promise<void> | null => pushInFlight
307
-
308
- // Checkpoint the agent's committed work to the branch periodically so an eviction
309
- // mid-run doesn't lose it (a retry then resumes from the pushed commits). The
310
- // agent commits its own work; this only PUSHES already-committed commits, so it
311
- // never races the agent's staging. Best-effort: a failed checkpoint is skipped.
312
- // Surface checkpoint-push failures at warn with a running count: a checkpoint losing
313
- // a race is harmless once, but a steadily-climbing count means mid-run work is NOT
314
- // being durably checkpointed, so an eviction would lose it — previously invisible at
315
- // info level. Still best-effort: a failed checkpoint never fails the run.
316
- let checkpointFailures = 0
317
- const checkpoint = setInterval(() => {
318
- pushWorkOnce().catch((err) => {
319
- checkpointFailures++
320
- logger.warn('coding-agent: checkpoint push failed', {
321
- reason: err instanceof Error ? err.message : String(err),
322
- checkpointFailures,
323
- })
324
- })
325
- }, checkpointIntervalMs())
326
- checkpoint.unref?.()
347
+ // The work-branch push machinery: one coalesced in-flight push plus the periodic
348
+ // checkpoint that keeps mid-run commits durable across an eviction. Lifted into
349
+ // {@link createWorkBranchPusher} so this callback stays within the per-function line budget;
350
+ // the invariants it upholds are documented there.
351
+ const { pushWorkOnce, inFlightPush, checkpoint } = createWorkBranchPusher({
352
+ dir,
353
+ spec,
354
+ baseSha,
355
+ logger,
356
+ signal,
357
+ })
327
358
 
328
359
  // In a monorepo the service lives in a subdirectory: run Pi with its cwd set to
329
360
  // that subtree (git stays rooted at `dir` so commits/pushes still cover the whole