@cat-factory/executor-harness 1.50.16 → 1.50.18

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.
@@ -319,21 +319,7 @@ export async function runClaudeCode(opts) {
319
319
  : join(homedir(), '.claude', 'skills');
320
320
  await writeNativeSkill(skillsRoot, opts.skill).catch(() => { });
321
321
  }
322
- // Anthropic itself authenticates with the subscription OAuth token; a
323
- // non-Anthropic Claude-Code vendor (GLM via Z.ai, Kimi via Moonshot, DeepSeek)
324
- // points Claude Code at its Anthropic-compatible endpoint with an auth-token key.
325
- // Ambient mode injects neither — the CLI uses the developer's logged-in `~/.claude`.
326
- const env = opts.ambientAuth
327
- ? {}
328
- : {
329
- CLAUDE_CONFIG_DIR: configHome,
330
- ...(opts.subscriptionBaseUrl
331
- ? {
332
- ANTHROPIC_BASE_URL: opts.subscriptionBaseUrl,
333
- ANTHROPIC_AUTH_TOKEN: opts.subscriptionToken,
334
- }
335
- : { CLAUDE_CODE_OAUTH_TOKEN: opts.subscriptionToken }),
336
- };
322
+ const env = buildClaudeEnv(opts, configHome);
337
323
  // ADR 0026 D3 (path corrected by ADR 0027 Defect A): while the run is live, tail the CLI's
338
324
  // subagent `*.jsonl` transcripts so a parallel-subagent review keeps the inactivity
339
325
  // heartbeat alive (any new bytes ⇒ `onActivity`) and its otherwise-invisible token spend is
@@ -369,38 +355,7 @@ export async function runClaudeCode(opts) {
369
355
  ...appendArgs,
370
356
  ],
371
357
  }, prompt, opts, env, opts.subscriptionToken ? secretsToRedact(opts.subscriptionToken) : [], onEvent);
372
- // The parent's cumulative-usage fallback applies to the PARENT calls only (before the
373
- // subagent calls, which carry their own per-turn tokens, are concatenated).
374
- attributeCumulativeUsage(calls, usage);
375
- // Final drain of any subagent transcript writes that landed after the last poll, then
376
- // fold the subagents' usage + per-call telemetry into the run's outcome — their tokens
377
- // never appear on the parent stream, so this is the only place they are accounted.
378
- await subagents?.stop();
379
- const subUsage = subagents?.usage() ?? { inputTokens: 0, outputTokens: 0 };
380
- const subCalls = subagents?.calls() ?? [];
381
- const mergedCalls = [...calls, ...subCalls];
382
- // INVARIANT (do not "fix" this into a double count): the run total is the parent usage
383
- // PLUS the subagent usage because the two are disjoint sources. The parent `usage` here
384
- // is the terminal `result` event's cumulative, which covers ONLY the parent loop — the
385
- // ADR 0026 incident is itself the proof: a heavily subagent-parallelised review reported
386
- // ~0 tokens, i.e. the parent stream (and its `result` total) never included the subagent
387
- // spend. The subagent tokens live exclusively in the per-session `subagents/*.jsonl`
388
- // transcripts, which the watcher reads and nothing else does; it deliberately EXCLUDES the
389
- // sibling parent session transcript (whose usage `result` already totals), so neither
390
- // `calls` nor `usage` can already contain the subagent spend.
391
- const mergedUsage = usage || subUsage.inputTokens || subUsage.outputTokens
392
- ? {
393
- inputTokens: (usage?.inputTokens ?? 0) + subUsage.inputTokens,
394
- outputTokens: (usage?.outputTokens ?? 0) + subUsage.outputTokens,
395
- }
396
- : undefined;
397
- return {
398
- summary,
399
- stats,
400
- stderrTail,
401
- ...(mergedUsage ? { usage: mergedUsage } : {}),
402
- ...(mergedCalls.length ? { callMetrics: mergedCalls } : {}),
403
- };
358
+ return await assembleClaudeOutcome({ summary, stats, stderrTail, calls, usage, subagents });
404
359
  }
405
360
  finally {
406
361
  await subagents?.stop();
@@ -417,6 +372,59 @@ export async function runClaudeCode(opts) {
417
372
  }
418
373
  }
419
374
  }
375
+ /**
376
+ * Build the child-process env for the `claude` CLI: an isolated config home plus subscription
377
+ * auth (Anthropic OAuth token, or an Anthropic-compatible base URL + auth token for a
378
+ * non-Anthropic Claude-Code vendor like GLM/Kimi/DeepSeek), or an empty env in ambient mode
379
+ * (the developer's own logged-in `~/.claude` is used). Extracted from {@link runClaudeCode} to
380
+ * keep its cyclomatic complexity down; behaviour is a straight move of the original expression.
381
+ */
382
+ function buildClaudeEnv(opts, configHome) {
383
+ if (opts.ambientAuth)
384
+ return {};
385
+ return {
386
+ CLAUDE_CONFIG_DIR: configHome,
387
+ ...(opts.subscriptionBaseUrl
388
+ ? {
389
+ ANTHROPIC_BASE_URL: opts.subscriptionBaseUrl,
390
+ ANTHROPIC_AUTH_TOKEN: opts.subscriptionToken,
391
+ }
392
+ : { CLAUDE_CODE_OAUTH_TOKEN: opts.subscriptionToken }),
393
+ };
394
+ }
395
+ /**
396
+ * Merge the parent-loop telemetry with the subagents' out-of-band usage + per-call metrics into
397
+ * the run outcome. INVARIANT (do not "fix" this into a double count): the run total is the parent
398
+ * usage PLUS the subagent usage because the two are disjoint sources — the parent `usage` (the
399
+ * terminal `result` event's cumulative) covers ONLY the parent loop, and the subagent tokens live
400
+ * exclusively in the per-session `subagents/*.jsonl` transcripts the watcher reads. Extracted from
401
+ * {@link runClaudeCode} verbatim to keep its cyclomatic complexity down.
402
+ */
403
+ async function assembleClaudeOutcome(args) {
404
+ const { summary, stats, stderrTail, calls, usage, subagents } = args;
405
+ // The parent's cumulative-usage fallback applies to the PARENT calls only (before the
406
+ // subagent calls, which carry their own per-turn tokens, are concatenated).
407
+ attributeCumulativeUsage(calls, usage);
408
+ // Final drain of any subagent transcript writes that landed after the last poll, then
409
+ // fold the subagents' usage + per-call telemetry into the run's outcome.
410
+ await subagents?.stop();
411
+ const subUsage = subagents?.usage() ?? { inputTokens: 0, outputTokens: 0 };
412
+ const subCalls = subagents?.calls() ?? [];
413
+ const mergedCalls = [...calls, ...subCalls];
414
+ const mergedUsage = usage || subUsage.inputTokens || subUsage.outputTokens
415
+ ? {
416
+ inputTokens: (usage?.inputTokens ?? 0) + subUsage.inputTokens,
417
+ outputTokens: (usage?.outputTokens ?? 0) + subUsage.outputTokens,
418
+ }
419
+ : undefined;
420
+ return {
421
+ summary,
422
+ stats,
423
+ stderrTail,
424
+ ...(mergedUsage ? { usage: mergedUsage } : {}),
425
+ ...(mergedCalls.length ? { callMetrics: mergedCalls } : {}),
426
+ };
427
+ }
420
428
  /** Map Claude Code's `TodoWrite` todos array onto subtask counts. */
421
429
  function todosToProgress(todos) {
422
430
  if (!Array.isArray(todos))
package/dist/agent.js CHANGED
@@ -745,14 +745,12 @@ async function runCodingMode(job, opts) {
745
745
  return result;
746
746
  }
747
747
  /**
748
- * The ordinary single-repo coding flow: clone `branch` (or resume `newBranch`), run the agent,
749
- * commit + push to `pushBranch`, and open `pr` when one is set and the run produced changes. A
750
- * no-op is a failure for the implementer (`noChangesIsError` default) and a non-fatal no-op for
751
- * the in-place fixers (and for a seed-only kind like `repro-test`).
748
+ * Assemble the {@link runCodingAgent} spec for the ordinary single-repo coding flow. Extracted
749
+ * from {@link runSingleRepoCoding} so the many optional-field spreads don't inflate that
750
+ * function's cyclomatic complexity; the mapping is a straight field copy off `job`.
752
751
  */
753
- async function runSingleRepoCoding(job, opts) {
754
- const pushBranch = job.pushBranch ?? job.newBranch ?? job.branch;
755
- const { summary, stats, stderrTail, pushed, usage, callMetrics, validation, effortReport } = await runCodingAgent({
752
+ function buildSingleRepoCodingSpec(job, pushBranch) {
753
+ return {
756
754
  kind: 'agent',
757
755
  jobId: job.jobId,
758
756
  repo: job.repo,
@@ -789,7 +787,17 @@ async function runSingleRepoCoding(job, opts) {
789
787
  },
790
788
  }
791
789
  : {}),
792
- }, opts);
790
+ };
791
+ }
792
+ /**
793
+ * The ordinary single-repo coding flow: clone `branch` (or resume `newBranch`), run the agent,
794
+ * commit + push to `pushBranch`, and open `pr` when one is set and the run produced changes. A
795
+ * no-op is a failure for the implementer (`noChangesIsError` default) and a non-fatal no-op for
796
+ * the in-place fixers (and for a seed-only kind like `repro-test`).
797
+ */
798
+ async function runSingleRepoCoding(job, opts) {
799
+ const pushBranch = job.pushBranch ?? job.newBranch ?? job.branch;
800
+ const { summary, stats, stderrTail, pushed, usage, callMetrics, validation, effortReport } = await runCodingAgent(buildSingleRepoCodingSpec(job, pushBranch), opts);
793
801
  // Ralph loop: the harness-computed validation verdict, forwarded onto the coding result as
794
802
  // `ralphVerdict` so the backend's `toRunResult` lifts it onto `AgentRunResult.ralphVerdict`.
795
803
  const ralphVerdict = validation ? { ralphVerdict: validation } : {};
package/dist/job.js CHANGED
@@ -555,6 +555,24 @@ function isReservedEnvName(key) {
555
555
  const lower = key.toLowerCase();
556
556
  return RESERVED_ENV_PREFIXES.some((p) => lower.startsWith(p));
557
557
  }
558
+ /**
559
+ * Collect only string→string entries from a raw `env` bag. A non-string value is dropped so a
560
+ * malformed binding can't inject `[object Object]` (or undefined) as an upstream URL. Reserved
561
+ * names that would break the toolchain or enable injection (PATH, NODE_OPTIONS, LD_PRELOAD, …) are
562
+ * dropped too: they are spread over `process.env` at build time, so a binding named `PATH` would
563
+ * replace it with a URL and the build would no longer find its tools. Extracted from the infra
564
+ * parsers to keep their cyclomatic complexity down.
565
+ */
566
+ function parseInfraEnv(raw) {
567
+ const env = {};
568
+ if (typeof raw === 'object' && raw !== null) {
569
+ for (const [key, val] of Object.entries(raw)) {
570
+ if (key && !isReservedEnvName(key) && typeof val === 'string')
571
+ env[key] = val;
572
+ }
573
+ }
574
+ return env;
575
+ }
558
576
  /** Parse the frontend UI-test infra spec (`kind: 'frontend'`), tolerating missing knobs. */
559
577
  function parseFrontendInfraSpec(o) {
560
578
  const packageManager = o.packageManager === 'pnpm' || o.packageManager === 'npm' || o.packageManager === 'yarn'
@@ -562,18 +580,7 @@ function parseFrontendInfraSpec(o) {
562
580
  : undefined;
563
581
  const serveMode = o.serveMode === 'static' || o.serveMode === 'command' ? o.serveMode : undefined;
564
582
  const envInjection = o.envInjection === 'build' || o.envInjection === 'runtime' ? o.envInjection : undefined;
565
- // Only string→string entries survive; a non-string value is dropped so a malformed
566
- // binding can't inject `[object Object]` (or undefined) as an upstream URL. Reserved names
567
- // that would break the toolchain or enable injection (PATH, NODE_OPTIONS, LD_PRELOAD, …) are
568
- // dropped too: they are spread over `process.env` at build time, so a binding named `PATH`
569
- // would replace it with a URL and the build would no longer find its tools.
570
- const env = {};
571
- if (typeof o.env === 'object' && o.env !== null) {
572
- for (const [key, val] of Object.entries(o.env)) {
573
- if (key && !isReservedEnvName(key) && typeof val === 'string')
574
- env[key] = val;
575
- }
576
- }
583
+ const env = parseInfraEnv(o.env);
577
584
  const servePort = port(o.servePort);
578
585
  const wiremockPort = port(o.wiremockPort);
579
586
  // The app's monorepo subdirectory becomes the install/build/serve cwd, so it goes through the
@@ -732,11 +739,7 @@ function assembleAgentJob(o, mode, agentField, parts) {
732
739
  ghToken: str(o.ghToken, 'ghToken'),
733
740
  repo: parseRepoSpec(repo),
734
741
  branch: str(o.branch, 'branch'),
735
- ...(typeof o.githubApiBase === 'string' ? { githubApiBase: o.githubApiBase } : {}),
736
- ...(typeof o.webToolsGuidance === 'string' ? { webToolsGuidance: o.webToolsGuidance } : {}),
737
- ...(o.webSearch === true ? { webSearch: true } : {}),
738
- ...(o.full === true ? { full: true } : {}),
739
- ...(typeof o.mergeBase === 'string' && o.mergeBase ? { mergeBase: o.mergeBase } : {}),
742
+ ...collectOptionalRequestFields(o),
740
743
  ...(bootstrap ? { bootstrap } : {}),
741
744
  ...(output ? { output } : {}),
742
745
  ...(contextFiles.length ? { contextFiles } : {}),
@@ -744,20 +747,35 @@ function assembleAgentJob(o, mode, agentField, parts) {
744
747
  ...(skill ? { skill } : {}),
745
748
  ...(testSecrets.length ? { testSecrets } : {}),
746
749
  ...(infra ? { infra } : {}),
747
- ...(typeof o.newBranch === 'string' && o.newBranch ? { newBranch: o.newBranch } : {}),
748
- ...(typeof o.pushBranch === 'string' && o.pushBranch ? { pushBranch: o.pushBranch } : {}),
749
- ...(typeof o.commitMessage === 'string' && o.commitMessage
750
- ? { commitMessage: o.commitMessage }
751
- : {}),
752
750
  ...(pr ? { pr } : {}),
753
751
  ...(peerRepos.length ? { peerRepos } : {}),
754
752
  ...(referenceRepos.length ? { referenceRepos } : {}),
755
753
  ...(referenceBranches.length ? { referenceBranches } : {}),
756
754
  ...(reviewPrNumber !== undefined ? { reviewPrNumber } : {}),
755
+ ...(guardLimits ? { guardLimits } : {}),
756
+ ...(validation ? { validation } : {}),
757
+ };
758
+ }
759
+ /**
760
+ * The optional {@link AgentJob} fields read directly off the request `o` (booleans + trimmed
761
+ * strings). Extracted from {@link assembleAgentJob} to keep its cyclomatic complexity down; every
762
+ * key is unique so grouping the conditional spreads is behaviour-neutral (spread order is
763
+ * irrelevant with no colliding keys).
764
+ */
765
+ function collectOptionalRequestFields(o) {
766
+ return {
767
+ ...(typeof o.githubApiBase === 'string' ? { githubApiBase: o.githubApiBase } : {}),
768
+ ...(typeof o.webToolsGuidance === 'string' ? { webToolsGuidance: o.webToolsGuidance } : {}),
769
+ ...(o.webSearch === true ? { webSearch: true } : {}),
770
+ ...(o.full === true ? { full: true } : {}),
771
+ ...(typeof o.mergeBase === 'string' && o.mergeBase ? { mergeBase: o.mergeBase } : {}),
772
+ ...(typeof o.newBranch === 'string' && o.newBranch ? { newBranch: o.newBranch } : {}),
773
+ ...(typeof o.pushBranch === 'string' && o.pushBranch ? { pushBranch: o.pushBranch } : {}),
774
+ ...(typeof o.commitMessage === 'string' && o.commitMessage
775
+ ? { commitMessage: o.commitMessage }
776
+ : {}),
757
777
  ...(o.noChangesIsError === false ? { noChangesIsError: false } : {}),
758
778
  ...(o.persistentCheckout === true ? { persistentCheckout: true } : {}),
759
779
  ...(o.streamFollowUps === true ? { streamFollowUps: true } : {}),
760
- ...(guardLimits ? { guardLimits } : {}),
761
- ...(validation ? { validation } : {}),
762
780
  };
763
781
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/executor-harness",
3
- "version": "1.50.16",
3
+ "version": "1.50.18",
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",
@@ -26,8 +26,8 @@
26
26
  "hono": "^4.12.30",
27
27
  "typescript": "7.0.2",
28
28
  "vitest": "^4.1.10",
29
- "@cat-factory/server": "0.142.0",
30
- "@cat-factory/spend": "0.12.74"
29
+ "@cat-factory/server": "0.143.1",
30
+ "@cat-factory/spend": "0.12.75"
31
31
  },
32
32
  "scripts": {
33
33
  "build": "tsc -p tsconfig.json",
@@ -424,21 +424,7 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
424
424
  await writeNativeSkill(skillsRoot, opts.skill).catch(() => {})
425
425
  }
426
426
 
427
- // Anthropic itself authenticates with the subscription OAuth token; a
428
- // non-Anthropic Claude-Code vendor (GLM via Z.ai, Kimi via Moonshot, DeepSeek)
429
- // points Claude Code at its Anthropic-compatible endpoint with an auth-token key.
430
- // Ambient mode injects neither — the CLI uses the developer's logged-in `~/.claude`.
431
- const env: Record<string, string> = opts.ambientAuth
432
- ? {}
433
- : {
434
- CLAUDE_CONFIG_DIR: configHome!,
435
- ...(opts.subscriptionBaseUrl
436
- ? {
437
- ANTHROPIC_BASE_URL: opts.subscriptionBaseUrl,
438
- ANTHROPIC_AUTH_TOKEN: opts.subscriptionToken!,
439
- }
440
- : { CLAUDE_CODE_OAUTH_TOKEN: opts.subscriptionToken! }),
441
- }
427
+ const env = buildClaudeEnv(opts, configHome)
442
428
 
443
429
  // ADR 0026 D3 (path corrected by ADR 0027 Defect A): while the run is live, tail the CLI's
444
430
  // subagent `*.jsonl` transcripts so a parallel-subagent review keeps the inactivity
@@ -484,39 +470,7 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
484
470
  onEvent,
485
471
  )
486
472
 
487
- // The parent's cumulative-usage fallback applies to the PARENT calls only (before the
488
- // subagent calls, which carry their own per-turn tokens, are concatenated).
489
- attributeCumulativeUsage(calls, usage)
490
- // Final drain of any subagent transcript writes that landed after the last poll, then
491
- // fold the subagents' usage + per-call telemetry into the run's outcome — their tokens
492
- // never appear on the parent stream, so this is the only place they are accounted.
493
- await subagents?.stop()
494
- const subUsage = subagents?.usage() ?? { inputTokens: 0, outputTokens: 0 }
495
- const subCalls = subagents?.calls() ?? []
496
- const mergedCalls = [...calls, ...subCalls]
497
- // INVARIANT (do not "fix" this into a double count): the run total is the parent usage
498
- // PLUS the subagent usage because the two are disjoint sources. The parent `usage` here
499
- // is the terminal `result` event's cumulative, which covers ONLY the parent loop — the
500
- // ADR 0026 incident is itself the proof: a heavily subagent-parallelised review reported
501
- // ~0 tokens, i.e. the parent stream (and its `result` total) never included the subagent
502
- // spend. The subagent tokens live exclusively in the per-session `subagents/*.jsonl`
503
- // transcripts, which the watcher reads and nothing else does; it deliberately EXCLUDES the
504
- // sibling parent session transcript (whose usage `result` already totals), so neither
505
- // `calls` nor `usage` can already contain the subagent spend.
506
- const mergedUsage =
507
- usage || subUsage.inputTokens || subUsage.outputTokens
508
- ? {
509
- inputTokens: (usage?.inputTokens ?? 0) + subUsage.inputTokens,
510
- outputTokens: (usage?.outputTokens ?? 0) + subUsage.outputTokens,
511
- }
512
- : undefined
513
- return {
514
- summary,
515
- stats,
516
- stderrTail,
517
- ...(mergedUsage ? { usage: mergedUsage } : {}),
518
- ...(mergedCalls.length ? { callMetrics: mergedCalls } : {}),
519
- }
473
+ return await assembleClaudeOutcome({ summary, stats, stderrTail, calls, usage, subagents })
520
474
  } finally {
521
475
  await subagents?.stop()
522
476
  if (configHome) {
@@ -533,6 +487,71 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
533
487
  }
534
488
  }
535
489
 
490
+ /**
491
+ * Build the child-process env for the `claude` CLI: an isolated config home plus subscription
492
+ * auth (Anthropic OAuth token, or an Anthropic-compatible base URL + auth token for a
493
+ * non-Anthropic Claude-Code vendor like GLM/Kimi/DeepSeek), or an empty env in ambient mode
494
+ * (the developer's own logged-in `~/.claude` is used). Extracted from {@link runClaudeCode} to
495
+ * keep its cyclomatic complexity down; behaviour is a straight move of the original expression.
496
+ */
497
+ function buildClaudeEnv(
498
+ opts: SubscriptionRunOptions,
499
+ configHome: string | undefined,
500
+ ): Record<string, string> {
501
+ if (opts.ambientAuth) return {}
502
+ return {
503
+ CLAUDE_CONFIG_DIR: configHome!,
504
+ ...(opts.subscriptionBaseUrl
505
+ ? {
506
+ ANTHROPIC_BASE_URL: opts.subscriptionBaseUrl,
507
+ ANTHROPIC_AUTH_TOKEN: opts.subscriptionToken!,
508
+ }
509
+ : { CLAUDE_CODE_OAUTH_TOKEN: opts.subscriptionToken! }),
510
+ }
511
+ }
512
+
513
+ /**
514
+ * Merge the parent-loop telemetry with the subagents' out-of-band usage + per-call metrics into
515
+ * the run outcome. INVARIANT (do not "fix" this into a double count): the run total is the parent
516
+ * usage PLUS the subagent usage because the two are disjoint sources — the parent `usage` (the
517
+ * terminal `result` event's cumulative) covers ONLY the parent loop, and the subagent tokens live
518
+ * exclusively in the per-session `subagents/*.jsonl` transcripts the watcher reads. Extracted from
519
+ * {@link runClaudeCode} verbatim to keep its cyclomatic complexity down.
520
+ */
521
+ async function assembleClaudeOutcome(args: {
522
+ summary: string
523
+ stats: PiRunStats
524
+ stderrTail: string
525
+ calls: HarnessCallMetric[]
526
+ usage: { inputTokens: number; outputTokens: number } | undefined
527
+ subagents: ReturnType<typeof startSubagentWatcher> | undefined
528
+ }): Promise<PiRunOutcome> {
529
+ const { summary, stats, stderrTail, calls, usage, subagents } = args
530
+ // The parent's cumulative-usage fallback applies to the PARENT calls only (before the
531
+ // subagent calls, which carry their own per-turn tokens, are concatenated).
532
+ attributeCumulativeUsage(calls, usage)
533
+ // Final drain of any subagent transcript writes that landed after the last poll, then
534
+ // fold the subagents' usage + per-call telemetry into the run's outcome.
535
+ await subagents?.stop()
536
+ const subUsage = subagents?.usage() ?? { inputTokens: 0, outputTokens: 0 }
537
+ const subCalls = subagents?.calls() ?? []
538
+ const mergedCalls = [...calls, ...subCalls]
539
+ const mergedUsage =
540
+ usage || subUsage.inputTokens || subUsage.outputTokens
541
+ ? {
542
+ inputTokens: (usage?.inputTokens ?? 0) + subUsage.inputTokens,
543
+ outputTokens: (usage?.outputTokens ?? 0) + subUsage.outputTokens,
544
+ }
545
+ : undefined
546
+ return {
547
+ summary,
548
+ stats,
549
+ stderrTail,
550
+ ...(mergedUsage ? { usage: mergedUsage } : {}),
551
+ ...(mergedCalls.length ? { callMetrics: mergedCalls } : {}),
552
+ }
553
+ }
554
+
536
555
  /** Map Claude Code's `TodoWrite` todos array onto subtask counts. */
537
556
  function todosToProgress(todos: unknown): TodoProgress | undefined {
538
557
  if (!Array.isArray(todos)) return undefined
package/src/agent.ts CHANGED
@@ -903,6 +903,55 @@ async function runCodingMode(job: AgentJob, opts: RunOptions): Promise<AgentResu
903
903
  return result
904
904
  }
905
905
 
906
+ /**
907
+ * Assemble the {@link runCodingAgent} spec for the ordinary single-repo coding flow. Extracted
908
+ * from {@link runSingleRepoCoding} so the many optional-field spreads don't inflate that
909
+ * function's cyclomatic complexity; the mapping is a straight field copy off `job`.
910
+ */
911
+ function buildSingleRepoCodingSpec(
912
+ job: AgentJob,
913
+ pushBranch: string,
914
+ ): Parameters<typeof runCodingAgent>[0] {
915
+ return {
916
+ kind: 'agent',
917
+ jobId: job.jobId,
918
+ repo: job.repo,
919
+ cloneBranch: job.branch,
920
+ ...(job.newBranch ? { newBranch: job.newBranch } : {}),
921
+ pushBranch,
922
+ ghToken: job.ghToken,
923
+ systemPrompt: job.systemPrompt,
924
+ userPrompt: job.userPrompt,
925
+ model: job.model,
926
+ harness: job.harness,
927
+ subscriptionToken: job.subscriptionToken,
928
+ subscriptionBaseUrl: job.subscriptionBaseUrl,
929
+ ambientAuth: job.ambientAuth,
930
+ proxyBaseUrl: job.proxyBaseUrl,
931
+ sessionToken: job.sessionToken,
932
+ commitMessage: job.commitMessage ?? job.pr?.title ?? 'Agent changes',
933
+ webToolsGuidance: job.webToolsGuidance,
934
+ webSearchProxy: job.webSearch,
935
+ guardLimits: job.guardLimits,
936
+ ...(job.persistentCheckout ? { persistentCheckout: true } : {}),
937
+ ...(job.streamFollowUps ? { streamFollowUps: true } : {}),
938
+ ...(job.referenceBranches?.length ? { referenceBranches: job.referenceBranches } : {}),
939
+ // Repo-sourced skill (slice 2): installed harness-aware by runAgentInWorkspace.
940
+ ...(job.skill ? { skill: job.skill } : {}),
941
+ // Ralph loop: run the completion command after the agent commits and report its verdict.
942
+ ...(job.validation
943
+ ? {
944
+ validation: {
945
+ command: job.validation.command,
946
+ ...(job.validation.iteration !== undefined
947
+ ? { iteration: job.validation.iteration }
948
+ : {}),
949
+ },
950
+ }
951
+ : {}),
952
+ }
953
+ }
954
+
906
955
  /**
907
956
  * The ordinary single-repo coding flow: clone `branch` (or resume `newBranch`), run the agent,
908
957
  * commit + push to `pushBranch`, and open `pr` when one is set and the run produced changes. A
@@ -912,47 +961,7 @@ async function runCodingMode(job: AgentJob, opts: RunOptions): Promise<AgentResu
912
961
  async function runSingleRepoCoding(job: AgentJob, opts: RunOptions): Promise<AgentResult> {
913
962
  const pushBranch = job.pushBranch ?? job.newBranch ?? job.branch
914
963
  const { summary, stats, stderrTail, pushed, usage, callMetrics, validation, effortReport } =
915
- await runCodingAgent(
916
- {
917
- kind: 'agent',
918
- jobId: job.jobId,
919
- repo: job.repo,
920
- cloneBranch: job.branch,
921
- ...(job.newBranch ? { newBranch: job.newBranch } : {}),
922
- pushBranch,
923
- ghToken: job.ghToken,
924
- systemPrompt: job.systemPrompt,
925
- userPrompt: job.userPrompt,
926
- model: job.model,
927
- harness: job.harness,
928
- subscriptionToken: job.subscriptionToken,
929
- subscriptionBaseUrl: job.subscriptionBaseUrl,
930
- ambientAuth: job.ambientAuth,
931
- proxyBaseUrl: job.proxyBaseUrl,
932
- sessionToken: job.sessionToken,
933
- commitMessage: job.commitMessage ?? job.pr?.title ?? 'Agent changes',
934
- webToolsGuidance: job.webToolsGuidance,
935
- webSearchProxy: job.webSearch,
936
- guardLimits: job.guardLimits,
937
- ...(job.persistentCheckout ? { persistentCheckout: true } : {}),
938
- ...(job.streamFollowUps ? { streamFollowUps: true } : {}),
939
- ...(job.referenceBranches?.length ? { referenceBranches: job.referenceBranches } : {}),
940
- // Repo-sourced skill (slice 2): installed harness-aware by runAgentInWorkspace.
941
- ...(job.skill ? { skill: job.skill } : {}),
942
- // Ralph loop: run the completion command after the agent commits and report its verdict.
943
- ...(job.validation
944
- ? {
945
- validation: {
946
- command: job.validation.command,
947
- ...(job.validation.iteration !== undefined
948
- ? { iteration: job.validation.iteration }
949
- : {}),
950
- },
951
- }
952
- : {}),
953
- },
954
- opts,
955
- )
964
+ await runCodingAgent(buildSingleRepoCodingSpec(job, pushBranch), opts)
956
965
  // Ralph loop: the harness-computed validation verdict, forwarded onto the coding result as
957
966
  // `ralphVerdict` so the backend's `toRunResult` lifts it onto `AgentRunResult.ralphVerdict`.
958
967
  const ralphVerdict = validation ? { ralphVerdict: validation } : {}
package/src/job.ts CHANGED
@@ -1124,6 +1124,24 @@ function isReservedEnvName(key: string): boolean {
1124
1124
  return RESERVED_ENV_PREFIXES.some((p) => lower.startsWith(p))
1125
1125
  }
1126
1126
 
1127
+ /**
1128
+ * Collect only string→string entries from a raw `env` bag. A non-string value is dropped so a
1129
+ * malformed binding can't inject `[object Object]` (or undefined) as an upstream URL. Reserved
1130
+ * names that would break the toolchain or enable injection (PATH, NODE_OPTIONS, LD_PRELOAD, …) are
1131
+ * dropped too: they are spread over `process.env` at build time, so a binding named `PATH` would
1132
+ * replace it with a URL and the build would no longer find its tools. Extracted from the infra
1133
+ * parsers to keep their cyclomatic complexity down.
1134
+ */
1135
+ function parseInfraEnv(raw: unknown): Record<string, string> {
1136
+ const env: Record<string, string> = {}
1137
+ if (typeof raw === 'object' && raw !== null) {
1138
+ for (const [key, val] of Object.entries(raw as Record<string, unknown>)) {
1139
+ if (key && !isReservedEnvName(key) && typeof val === 'string') env[key] = val
1140
+ }
1141
+ }
1142
+ return env
1143
+ }
1144
+
1127
1145
  /** Parse the frontend UI-test infra spec (`kind: 'frontend'`), tolerating missing knobs. */
1128
1146
  function parseFrontendInfraSpec(o: Record<string, unknown>): FrontendInfraSpec {
1129
1147
  const packageManager =
@@ -1133,17 +1151,7 @@ function parseFrontendInfraSpec(o: Record<string, unknown>): FrontendInfraSpec {
1133
1151
  const serveMode = o.serveMode === 'static' || o.serveMode === 'command' ? o.serveMode : undefined
1134
1152
  const envInjection =
1135
1153
  o.envInjection === 'build' || o.envInjection === 'runtime' ? o.envInjection : undefined
1136
- // Only string→string entries survive; a non-string value is dropped so a malformed
1137
- // binding can't inject `[object Object]` (or undefined) as an upstream URL. Reserved names
1138
- // that would break the toolchain or enable injection (PATH, NODE_OPTIONS, LD_PRELOAD, …) are
1139
- // dropped too: they are spread over `process.env` at build time, so a binding named `PATH`
1140
- // would replace it with a URL and the build would no longer find its tools.
1141
- const env: Record<string, string> = {}
1142
- if (typeof o.env === 'object' && o.env !== null) {
1143
- for (const [key, val] of Object.entries(o.env as Record<string, unknown>)) {
1144
- if (key && !isReservedEnvName(key) && typeof val === 'string') env[key] = val
1145
- }
1146
- }
1154
+ const env = parseInfraEnv(o.env)
1147
1155
  const servePort = port(o.servePort)
1148
1156
  const wiremockPort = port(o.wiremockPort)
1149
1157
  // The app's monorepo subdirectory becomes the install/build/serve cwd, so it goes through the
@@ -1376,11 +1384,7 @@ function assembleAgentJob(
1376
1384
  ghToken: str(o.ghToken, 'ghToken'),
1377
1385
  repo: parseRepoSpec(repo),
1378
1386
  branch: str(o.branch, 'branch'),
1379
- ...(typeof o.githubApiBase === 'string' ? { githubApiBase: o.githubApiBase } : {}),
1380
- ...(typeof o.webToolsGuidance === 'string' ? { webToolsGuidance: o.webToolsGuidance } : {}),
1381
- ...(o.webSearch === true ? { webSearch: true } : {}),
1382
- ...(o.full === true ? { full: true } : {}),
1383
- ...(typeof o.mergeBase === 'string' && o.mergeBase ? { mergeBase: o.mergeBase } : {}),
1387
+ ...collectOptionalRequestFields(o),
1384
1388
  ...(bootstrap ? { bootstrap } : {}),
1385
1389
  ...(output ? { output } : {}),
1386
1390
  ...(contextFiles.length ? { contextFiles } : {}),
@@ -1388,20 +1392,36 @@ function assembleAgentJob(
1388
1392
  ...(skill ? { skill } : {}),
1389
1393
  ...(testSecrets.length ? { testSecrets } : {}),
1390
1394
  ...(infra ? { infra } : {}),
1391
- ...(typeof o.newBranch === 'string' && o.newBranch ? { newBranch: o.newBranch } : {}),
1392
- ...(typeof o.pushBranch === 'string' && o.pushBranch ? { pushBranch: o.pushBranch } : {}),
1393
- ...(typeof o.commitMessage === 'string' && o.commitMessage
1394
- ? { commitMessage: o.commitMessage }
1395
- : {}),
1396
1395
  ...(pr ? { pr } : {}),
1397
1396
  ...(peerRepos.length ? { peerRepos } : {}),
1398
1397
  ...(referenceRepos.length ? { referenceRepos } : {}),
1399
1398
  ...(referenceBranches.length ? { referenceBranches } : {}),
1400
1399
  ...(reviewPrNumber !== undefined ? { reviewPrNumber } : {}),
1400
+ ...(guardLimits ? { guardLimits } : {}),
1401
+ ...(validation ? { validation } : {}),
1402
+ }
1403
+ }
1404
+
1405
+ /**
1406
+ * The optional {@link AgentJob} fields read directly off the request `o` (booleans + trimmed
1407
+ * strings). Extracted from {@link assembleAgentJob} to keep its cyclomatic complexity down; every
1408
+ * key is unique so grouping the conditional spreads is behaviour-neutral (spread order is
1409
+ * irrelevant with no colliding keys).
1410
+ */
1411
+ function collectOptionalRequestFields(o: Record<string, unknown>): Partial<AgentJob> {
1412
+ return {
1413
+ ...(typeof o.githubApiBase === 'string' ? { githubApiBase: o.githubApiBase } : {}),
1414
+ ...(typeof o.webToolsGuidance === 'string' ? { webToolsGuidance: o.webToolsGuidance } : {}),
1415
+ ...(o.webSearch === true ? { webSearch: true } : {}),
1416
+ ...(o.full === true ? { full: true } : {}),
1417
+ ...(typeof o.mergeBase === 'string' && o.mergeBase ? { mergeBase: o.mergeBase } : {}),
1418
+ ...(typeof o.newBranch === 'string' && o.newBranch ? { newBranch: o.newBranch } : {}),
1419
+ ...(typeof o.pushBranch === 'string' && o.pushBranch ? { pushBranch: o.pushBranch } : {}),
1420
+ ...(typeof o.commitMessage === 'string' && o.commitMessage
1421
+ ? { commitMessage: o.commitMessage }
1422
+ : {}),
1401
1423
  ...(o.noChangesIsError === false ? { noChangesIsError: false } : {}),
1402
1424
  ...(o.persistentCheckout === true ? { persistentCheckout: true } : {}),
1403
1425
  ...(o.streamFollowUps === true ? { streamFollowUps: true } : {}),
1404
- ...(guardLimits ? { guardLimits } : {}),
1405
- ...(validation ? { validation } : {}),
1406
1426
  }
1407
1427
  }