@cat-factory/executor-harness 1.52.0 → 1.54.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.
@@ -0,0 +1,217 @@
1
+ import { isObject } from './claude-stream.js';
2
+ // The parent agent's own PLAN, as progress counts. This is one of the two redundant views a
3
+ // pr-reviewer run produces (the other is the parallel-subagent dispatch view in
4
+ // `subagents.ts`); {@link pickProgress} reconciles them.
5
+ //
6
+ // The Claude Code CLI exposes the plan through TWO different tool vocabularies, and which one
7
+ // a run uses depends on the CLI build, not on anything the harness controls:
8
+ //
9
+ // - `TodoWrite` — one call carrying the WHOLE list (`todos[]`), each entry with its own
10
+ // status. Every call is a complete snapshot, so the last one wins.
11
+ // - `TaskCreate` / `TaskUpdate` — an incremental, id-keyed task list. `TaskCreate` appends a
12
+ // task and the CLI assigns its id in the tool RESULT; `TaskUpdate` moves one task by id.
13
+ //
14
+ // Both are live in the shipped schema (`sdk-tools.d.ts` in `@anthropic-ai/claude-code` declares
15
+ // `TodoWriteInput` AND `TaskCreateInput`/`TaskUpdateInput`), so the harness tracks both rather
16
+ // than betting on one. Reading only `TodoWrite` is what pinned a CLI 2.1.x pr-review at 0%:
17
+ // the run planned entirely through `TaskCreate`/`TaskUpdate` and the harness saw nothing.
18
+ //
19
+ // Everything here is best-effort and defensive: an unknown status, a missing id, or a result
20
+ // string the CLI reworded degrades to "no progress from this signal" rather than throwing. The
21
+ // tool vocabulary is not a stable contract, so this module may only ever ADD signal.
22
+ /** Statuses a plan entry can carry; anything unrecognised is treated as not-yet-started. */
23
+ export function normalizeStatus(status) {
24
+ if (status === 'completed')
25
+ return 'completed';
26
+ if (status === 'in_progress')
27
+ return 'in_progress';
28
+ return 'pending';
29
+ }
30
+ /** Roll a label+status list up into the counts the board renders. Shared by every plan shape. */
31
+ export function toProgress(items) {
32
+ return {
33
+ completed: items.filter((i) => i.status === 'completed').length,
34
+ inProgress: items.filter((i) => i.status === 'in_progress').length,
35
+ total: items.length,
36
+ items,
37
+ };
38
+ }
39
+ /** Map a `TodoWrite` call's `todos` array onto subtask counts. Each call is a full snapshot. */
40
+ export function todosToProgress(todos) {
41
+ if (!Array.isArray(todos))
42
+ return undefined;
43
+ return toProgress(todos.filter(isObject).map((t) => ({
44
+ label: typeof t.content === 'string' ? t.content : String(t.content ?? ''),
45
+ status: normalizeStatus(t.status),
46
+ })));
47
+ }
48
+ /**
49
+ * The id the CLI assigned to a just-created task, read from `TaskCreate`'s tool RESULT.
50
+ *
51
+ * `TaskCreate`'s INPUT carries only `{subject, description}` — the id is minted by the CLI and
52
+ * comes back on the result, so pairing a later `TaskUpdate({taskId})` to the task it created
53
+ * requires reading the result text. The CLI's shipped `TaskCreateOutput` is
54
+ * `{task: {id, subject}}`, but the parent stream's `tool_result` block carries the rendered
55
+ * STRING (`"Task #1 created successfully: <subject>"`), so both shapes are accepted.
56
+ */
57
+ export function parseCreatedTaskId(content) {
58
+ if (isObject(content)) {
59
+ const task = isObject(content.task) ? content.task : undefined;
60
+ const id = task?.id;
61
+ if (typeof id === 'string' && id.trim())
62
+ return id.trim();
63
+ if (typeof id === 'number')
64
+ return String(id);
65
+ }
66
+ const text = typeof content === 'string'
67
+ ? content
68
+ : Array.isArray(content)
69
+ ? content
70
+ .filter(isObject)
71
+ .map((b) => (typeof b.text === 'string' ? b.text : ''))
72
+ .join('\n')
73
+ : '';
74
+ return /\bTask\s+#(\d+)\b/i.exec(text)?.[1];
75
+ }
76
+ export function createTaskPlanTracker() {
77
+ // Insertion-ordered so `items` render in plan order.
78
+ const tasks = new Map();
79
+ // tool_use id of an unresolved `TaskCreate` -> the synthetic key it was filed under, so the
80
+ // task can be re-keyed to its real id once the result lands.
81
+ const pendingCreates = new Map();
82
+ // Updates that arrived before their target was bound (the CLI can interleave), replayed on bind.
83
+ const orphanUpdates = new Map();
84
+ // `deleted` tombstones for a task id whose create has not bound yet, replayed on bind — else a
85
+ // delete that races ahead of its create leaves the task in the plan forever.
86
+ const pendingDeletes = new Set();
87
+ const apply = (task, patch) => {
88
+ if (patch.label)
89
+ task.label = patch.label;
90
+ if (patch.status)
91
+ task.status = patch.status;
92
+ };
93
+ // Drop a tombstoned task. When it isn't present yet (its create hasn't bound), remember the
94
+ // tombstone so the bind drops it rather than leaving it stuck in the plan forever.
95
+ const markDeleted = (taskId) => {
96
+ if (!tasks.delete(taskId))
97
+ pendingDeletes.add(taskId);
98
+ orphanUpdates.delete(taskId);
99
+ };
100
+ return {
101
+ onAssistant(content) {
102
+ if (!Array.isArray(content))
103
+ return;
104
+ for (const block of content) {
105
+ if (!isObject(block) || block.type !== 'tool_use')
106
+ continue;
107
+ const input = isObject(block.input) ? block.input : {};
108
+ if (block.name === 'TaskCreate') {
109
+ const toolUseId = typeof block.id === 'string' ? block.id : undefined;
110
+ if (!toolUseId || pendingCreates.has(toolUseId))
111
+ continue;
112
+ const label = (typeof input.subject === 'string' && input.subject.trim()) ||
113
+ (typeof input.description === 'string' && input.description.trim()) ||
114
+ `Task ${tasks.size + 1}`;
115
+ const key = `pending:${toolUseId}`;
116
+ tasks.set(key, { id: key, label, status: 'pending' });
117
+ pendingCreates.set(toolUseId, key);
118
+ }
119
+ else if (block.name === 'TaskUpdate') {
120
+ const taskId = typeof input.taskId === 'string' ? input.taskId : undefined;
121
+ if (!taskId)
122
+ continue;
123
+ const patch = {};
124
+ if (typeof input.subject === 'string' && input.subject.trim())
125
+ patch.label = input.subject.trim();
126
+ if (input.status === 'deleted') {
127
+ // `deleted` is a tombstone, not a status — drop the task from the live list.
128
+ markDeleted(taskId);
129
+ continue;
130
+ }
131
+ if (input.status !== undefined)
132
+ patch.status = normalizeStatus(input.status);
133
+ const task = tasks.get(taskId);
134
+ if (task)
135
+ apply(task, patch);
136
+ else
137
+ orphanUpdates.set(taskId, { ...orphanUpdates.get(taskId), ...patch });
138
+ }
139
+ }
140
+ },
141
+ onUser(content) {
142
+ if (!Array.isArray(content))
143
+ return;
144
+ for (const block of content) {
145
+ if (!isObject(block) || block.type !== 'tool_result')
146
+ continue;
147
+ const toolUseId = typeof block.tool_use_id === 'string' ? block.tool_use_id : undefined;
148
+ const key = toolUseId ? pendingCreates.get(toolUseId) : undefined;
149
+ if (!key)
150
+ continue;
151
+ const taskId = parseCreatedTaskId(block.content);
152
+ pendingCreates.delete(toolUseId);
153
+ const task = tasks.get(key);
154
+ // No parsable id ⇒ leave it filed under its synthetic key: it still counts toward the
155
+ // plan total, it just can never be advanced by a later `TaskUpdate`. A parsed id that
156
+ // already names a live task (a duplicate / misparse) is also left under the synthetic key
157
+ // rather than overwriting that task — the rebuild below would otherwise drop a row and
158
+ // undercount `total`.
159
+ if (!taskId || !task || taskId === key || tasks.has(taskId))
160
+ continue;
161
+ // Re-key in place. Rebuilding the map preserves insertion order, which `items` relies on.
162
+ const entries = [...tasks.entries()];
163
+ tasks.clear();
164
+ for (const [k, v] of entries) {
165
+ if (k !== key)
166
+ tasks.set(k, v);
167
+ else
168
+ tasks.set(taskId, { ...v, id: taskId });
169
+ }
170
+ // A tombstone that raced ahead of this bind drops the task now that it exists.
171
+ if (pendingDeletes.delete(taskId)) {
172
+ tasks.delete(taskId);
173
+ orphanUpdates.delete(taskId);
174
+ continue;
175
+ }
176
+ const pendingPatch = orphanUpdates.get(taskId);
177
+ if (pendingPatch) {
178
+ const bound = tasks.get(taskId);
179
+ if (bound)
180
+ apply(bound, pendingPatch);
181
+ orphanUpdates.delete(taskId);
182
+ }
183
+ }
184
+ },
185
+ progress() {
186
+ if (tasks.size === 0)
187
+ return undefined;
188
+ return toProgress([...tasks.values()].map((t) => ({ label: t.label, status: t.status })));
189
+ },
190
+ };
191
+ }
192
+ /**
193
+ * Reconcile the redundant views of the same work into the one to surface (ADR 0027 Defect B).
194
+ * A pr-reviewer run has BOTH a parent plan (`TodoWrite` or `TaskCreate`/`TaskUpdate`) and the
195
+ * `SliceTracker`'s subagent-dispatch view. The sequential shape advances the plan; the parallel
196
+ * shape advances ONLY the slice tracker (the reviewer writes its plan once and the parallel
197
+ * subagents report in-flight/complete). Neither alone covers both shapes, and gating the slice
198
+ * tracker off whenever a plan exists (the original behaviour) pinned parallel runs at 0%.
199
+ *
200
+ * So prefer whichever view is further along: more `completed`, then more `inProgress` (an
201
+ * all-pending plan must not beat live in-flight slices), then more `total` (the richer view — a
202
+ * plan can carry an extra "aggregate" entry), else the plan. Pure + total; returns whichever
203
+ * single input is present when only one is.
204
+ */
205
+ export function pickProgress(todo, slice) {
206
+ if (!todo)
207
+ return slice;
208
+ if (!slice)
209
+ return todo;
210
+ if (slice.completed !== todo.completed)
211
+ return slice.completed > todo.completed ? slice : todo;
212
+ if (slice.inProgress !== todo.inProgress)
213
+ return slice.inProgress > todo.inProgress ? slice : todo;
214
+ if (slice.total !== todo.total)
215
+ return slice.total > todo.total ? slice : todo;
216
+ return todo;
217
+ }
package/dist/subagents.js CHANGED
@@ -3,6 +3,55 @@ import { createReadStream } from 'node:fs';
3
3
  import { basename, join } from 'node:path';
4
4
  import { claudeAssistantContent, claudeCallUsage, isObject, redactBody } from './claude-stream.js';
5
5
  import { publishCallMetric } from './pi.js';
6
+ // ADR 0026 D2.1 + D3, corrected by ADR 0027. When the Claude Code CLI reviews a large PR
7
+ // it fans the work out across parallel `Task` subagents. Two things then go dark to the
8
+ // harness, which only reads the PARENT process's stream-json stdout:
9
+ //
10
+ // - the parent stream falls quiet for the whole (potentially 15+ minute) parallel
11
+ // review, so the inactivity heartbeat freezes and a healthy run looks wedged (P3);
12
+ // - every subagent's token spend is written to a SEPARATE `subagents/*.jsonl`
13
+ // transcript under the CLI's config home and never reaches the parent stream, so
14
+ // the run's telemetry reports ~0 tokens while hundreds of thousands are spent (P3).
15
+ //
16
+ // This module closes both without disabling the (context-bounding, ADR-0023-wanted)
17
+ // subagent parallelism:
18
+ //
19
+ // - {@link createSliceTracker} derives the slice plan + per-slice progress from the
20
+ // PARENT stream alone — the subagent-dispatch tool_use and its terminal tool_result
21
+ // DO appear there (only the subagent's intermediate turns don't), so slices/progress
22
+ // need no file watching (D2.1). `pickProgress` (./progress.ts) reconciles it with the
23
+ // parent's own plan (ADR 0027 Defect B);
24
+ // - {@link startSubagentWatcher} tails the `subagents/*.jsonl` transcripts for the
25
+ // heartbeat (any new bytes ⇒ `onActivity`) and sums each subagent turn's usage into
26
+ // the run's telemetry (D3).
27
+ //
28
+ // The CLI does NOT write those transcripts to `<configHome>/subagents` (the location ADR
29
+ // 0026 assumed, which never exists — ADR 0027 Defect A). It writes them PER SESSION under
30
+ // `<configHome>/projects/<encoded-cwd>/<session-uuid>/subagents/agent-*.jsonl`, and the
31
+ // session-uuid dir isn't known before the CLI mints it — so the watcher is pointed at the
32
+ // `projects` root and DISCOVERS the `subagents/` dir by walking (see
33
+ // {@link findSubagentTranscripts}).
34
+ //
35
+ // Both degrade gracefully: the CLI's subagent transcript layout is not a stable contract,
36
+ // so a missing directory, an unreadable file, or an unparseable line is swallowed and the
37
+ // harness falls back to today's parent-stream-only behaviour.
38
+ // ---------------------------------------------------------------------------
39
+ // Slice / progress tracking off the PARENT stream (D2.1)
40
+ // ---------------------------------------------------------------------------
41
+ /**
42
+ * The tool names the Claude Code CLI dispatches a parallel subagent under. `Agent` is what the
43
+ * shipped schema declares (`AgentInput` in `sdk-tools.d.ts`, carrying `description` / `prompt` /
44
+ * `subagent_type`); `Task` is the older name for the same dispatch. Both are matched because the
45
+ * harness runs against whatever CLI the image happens to bundle, and matching only the old name
46
+ * is what left a CLI 2.1.x pr-review reporting no slices at all.
47
+ *
48
+ * Note the asymmetry: keeping the legacy `Task` here is the one place a CLI rename could produce a
49
+ * FALSE signal rather than merely no signal — if a future build were to name a plain task-list
50
+ * tool `Task`, its writes would be counted as in-flight slices. We accept that because no shipped
51
+ * build does (the incremental plan tool is `TaskCreate`/`TaskUpdate`, tracked separately in
52
+ * `progress.ts`), and dropping legacy coverage is the more likely regression.
53
+ */
54
+ const SUBAGENT_TOOL_NAMES = new Set(['Agent', 'Task']);
6
55
  export function createSliceTracker() {
7
56
  // Insertion-ordered so the progress `items` render in dispatch order.
8
57
  const slices = new Map();
@@ -11,7 +60,9 @@ export function createSliceTracker() {
11
60
  if (!Array.isArray(content))
12
61
  return;
13
62
  for (const block of content) {
14
- if (!isObject(block) || block.type !== 'tool_use' || block.name !== 'Task')
63
+ if (!isObject(block) || block.type !== 'tool_use')
64
+ continue;
65
+ if (typeof block.name !== 'string' || !SUBAGENT_TOOL_NAMES.has(block.name))
15
66
  continue;
16
67
  const id = typeof block.id === 'string' ? block.id : undefined;
17
68
  if (!id || slices.has(id))
@@ -55,32 +106,6 @@ export function createSliceTracker() {
55
106
  },
56
107
  };
57
108
  }
58
- /**
59
- * Reconcile the two redundant views of the same slice work into the one to surface
60
- * (ADR 0027 Defect B). A pr-reviewer run has BOTH a parent `TodoWrite` plan (the slices,
61
- * written once at grouping time) and the {@link SliceTracker}'s `Task`-dispatch view. The
62
- * sequential shape advances the todo plan; the parallel-subagent shape advances ONLY the
63
- * slice tracker (the CLI writes the plan once and never marks it done, while the parallel
64
- * `Task`s report in-flight/complete). Neither alone covers both shapes, and gating the
65
- * slice tracker OFF whenever a todo plan exists (the old behaviour) pinned parallel runs
66
- * at 0%. So prefer whichever view is further along: more `completed`, then more
67
- * `inProgress` (an all-pending todo plan must not beat live in-flight slices), then more
68
- * `total` (the richer view — the todo plan can carry an extra "aggregate" entry), else the
69
- * todo plan. Pure + total; returns whichever single input is present when only one is.
70
- */
71
- export function pickProgress(todo, slice) {
72
- if (!todo)
73
- return slice;
74
- if (!slice)
75
- return todo;
76
- if (slice.completed !== todo.completed)
77
- return slice.completed > todo.completed ? slice : todo;
78
- if (slice.inProgress !== todo.inProgress)
79
- return slice.inProgress > todo.inProgress ? slice : todo;
80
- if (slice.total !== todo.total)
81
- return slice.total > todo.total ? slice : todo;
82
- return todo;
83
- }
84
109
  // ---------------------------------------------------------------------------
85
110
  // Subagent transcript watcher (heartbeat + usage) (D3)
86
111
  // ---------------------------------------------------------------------------
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/executor-harness",
3
- "version": "1.52.0",
3
+ "version": "1.54.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",
@@ -26,7 +26,7 @@
26
26
  "hono": "^4.12.30",
27
27
  "typescript": "7.0.2",
28
28
  "vitest": "^4.1.10",
29
- "@cat-factory/server": "0.144.0",
29
+ "@cat-factory/server": "0.144.4",
30
30
  "@cat-factory/spend": "0.12.77"
31
31
  },
32
32
  "scripts": {
@@ -1,6 +1,6 @@
1
1
  import { spawn } from 'node:child_process'
2
2
  import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
3
- import { homedir, tmpdir } from 'node:os'
3
+ import { tmpdir } from 'node:os'
4
4
  import { dirname, join } from 'node:path'
5
5
  import {
6
6
  claudeAssistantContent,
@@ -21,7 +21,14 @@ import {
21
21
  } from './pi.js'
22
22
  import { killChildProcess, spawnDetached } from './process.js'
23
23
  import { redact, secretsToRedact } from './redact.js'
24
- import { createSliceTracker, pickProgress, startSubagentWatcher } from './subagents.js'
24
+ import { createSliceTracker, startSubagentWatcher } from './subagents.js'
25
+ import {
26
+ createTaskPlanTracker,
27
+ normalizeStatus,
28
+ pickProgress,
29
+ toProgress,
30
+ todosToProgress,
31
+ } from './progress.js'
25
32
  import { assertOnboardingKeysCurrent, writeOnboardingPreseed } from './onboarding-preseed.js'
26
33
  import { retainSessionTranscripts } from './transcript-retention.js'
27
34
 
@@ -74,8 +81,9 @@ export interface SubscriptionRunOptions {
74
81
  /**
75
82
  * A repo-sourced Claude Skill to install natively before launch (repo-sourced Claude Skills,
76
83
  * slice 2). The claude-code runner writes it to `CLAUDE_CONFIG_DIR/skills/<name>/SKILL.md`
77
- * (+ resource files) so the CLI loads it; the codex runner ignores it (codex reads the
78
- * checkout's `.cat-context/skill/`, materialised by the caller). Absent no skill installed.
84
+ * (+ resource files) so the CLI loads it but ONLY when it owns an isolated config home, i.e.
85
+ * NOT under `ambientAuth`. The codex runner ignores it outright. Every case that skips the
86
+ * native install reads the checkout's `.cat-context/skill/`, materialised by the caller.
79
87
  */
80
88
  skill?: {
81
89
  name: string
@@ -83,6 +91,14 @@ export interface SubscriptionRunOptions {
83
91
  instructions: string
84
92
  resources: { relPath: string; content: string }[]
85
93
  }
94
+ /**
95
+ * Extra environment for the CLI child, scoped to this job (the tester's secrets, a
96
+ * private-registry npmrc pointer). Merged over the inherited `process.env` at spawn, so the
97
+ * agent and its shell tools see them without the harness mutating its OWN environment — which
98
+ * is shared by every concurrent job under the native host-process transport. See
99
+ * `RunOptions.agentEnv`.
100
+ */
101
+ extraEnv?: Record<string, string>
86
102
  /** Aborting this kills the CLI (the job's inactivity/max-duration watchdog). */
87
103
  signal?: AbortSignal
88
104
  /** Called on every chunk of CLI output, so the watchdog sees the agent is alive. */
@@ -343,17 +359,26 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
343
359
  const publisher = createCallMetricPublisher(calls, opts.onCallMetric)
344
360
 
345
361
  // ADR 0026 D2.1 + ADR 0027 Defect B: surface live slice progress from TWO reconciled
346
- // sources. The parent's `Task` dispatches + their terminal tool_results DO appear on this
362
+ // sources. The parent's subagent dispatches + their terminal tool_results DO appear on this
347
363
  // stream (only a subagent's intermediate turns don't), so `sliceTracker` derives per-slice
348
- // progress for the parallel-subagent shape; a parent `TodoWrite` plan (the sequential
349
- // shape) is tracked in `lastTodo`. `pickProgress` picks whichever is further along on each
350
- // update, so neither masks the other — the pr-reviewer prompt writes its todo plan ONCE
351
- // and never marks it done, which used to gate the slice signal off and pin progress at 0%.
364
+ // progress for the parallel shape; the parent's own plan (the sequential shape) is tracked
365
+ // by `planTracker` + `lastTodo`. `pickProgress` picks whichever is further along on each
366
+ // update, so neither masks the other — the pr-reviewer prompt writes its plan ONCE and never
367
+ // marks it done, which used to gate the slice signal off and pin progress at 0%.
368
+ //
369
+ // The plan arrives in one of two tool vocabularies depending on the bundled CLI build:
370
+ // `TodoWrite` (whole-list snapshots, tracked in `lastTodo`) or the incremental
371
+ // `TaskCreate`/`TaskUpdate` pair (tracked by `planTracker`, which needs the tool RESULTS too
372
+ // because the task id is minted there). Both are read — see ./progress.ts.
352
373
  const sliceTracker = createSliceTracker()
374
+ const planTracker = createTaskPlanTracker()
353
375
  let lastTodo: TodoProgress | undefined
354
376
  const emitProgress = (): void => {
355
377
  if (!opts.onProgress) return
356
- const progress = pickProgress(lastTodo, sliceTracker.progress())
378
+ const progress = pickProgress(
379
+ pickProgress(lastTodo, planTracker.progress()),
380
+ sliceTracker.progress(),
381
+ )
357
382
  if (progress) opts.onProgress(progress)
358
383
  }
359
384
 
@@ -372,6 +397,7 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
372
397
  }
373
398
  }
374
399
  sliceTracker.onAssistant(content)
400
+ planTracker.onAssistant(content)
375
401
  emitProgress()
376
402
  // Record this call BEFORE appending its turn: the prompt is the history that
377
403
  // produced this response. The append-only array keeps each call's prompt a strict
@@ -394,6 +420,7 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
394
420
  const content = (event.message as Record<string, unknown>).content
395
421
  if (Array.isArray(content)) {
396
422
  sliceTracker.onUser(content)
423
+ planTracker.onUser(content)
397
424
  emitProgress()
398
425
  messages.push({ role: 'tool', content })
399
426
  }
@@ -431,14 +458,14 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
431
458
  }
432
459
 
433
460
  // Repo-sourced Claude Skill (slice 2): install it as a native skill under the config dir's
434
- // `skills/<name>/` so the CLI discovers and can invoke it. Written to the isolated per-run
435
- // config home when present, else the developer's `~/.claude` (ambient/native mode). Best-effort:
436
- // a write failure must not wedge the run the prompt still names the skill.
437
- if (opts.skill) {
438
- const skillsRoot = configHome
439
- ? join(configHome, 'skills')
440
- : join(homedir(), '.claude', 'skills')
441
- await writeNativeSkill(skillsRoot, opts.skill).catch(() => {})
461
+ // `skills/<name>/` so the CLI discovers and can invoke it. ONLY into the isolated per-run config
462
+ // home never the developer's own `~/.claude` (ambient/native mode), where it would persist in
463
+ // their personal setup after the run and two concurrent jobs carrying same-named skills from
464
+ // different repos would clobber each other. An ambient run reads the skill from the checkout
465
+ // instead (`.cat-context/skill/`, materialised by the caller). Best-effort: a write failure must
466
+ // not wedge the run — the prompt still names the skill.
467
+ if (opts.skill && configHome) {
468
+ await writeNativeSkill(join(configHome, 'skills'), opts.skill).catch(() => {})
442
469
  }
443
470
 
444
471
  const env = buildClaudeEnv(opts, configHome)
@@ -524,8 +551,11 @@ function buildClaudeEnv(
524
551
  opts: SubscriptionRunOptions,
525
552
  configHome: string | undefined,
526
553
  ): Record<string, string> {
527
- if (opts.ambientAuth) return {}
554
+ // The job-scoped env rides along in BOTH modes; the credential/config vars below are what
555
+ // ambient mode drops (the developer's own logged-in `~/.claude` is used instead).
556
+ if (opts.ambientAuth) return { ...opts.extraEnv }
528
557
  return {
558
+ ...opts.extraEnv,
529
559
  CLAUDE_CONFIG_DIR: configHome!,
530
560
  ...(opts.subscriptionBaseUrl
531
561
  ? {
@@ -584,24 +614,6 @@ async function assembleClaudeOutcome(args: {
584
614
  }
585
615
  }
586
616
 
587
- /** Map Claude Code's `TodoWrite` todos array onto subtask counts. */
588
- function todosToProgress(todos: unknown): TodoProgress | undefined {
589
- if (!Array.isArray(todos)) return undefined
590
- const items = todos.filter(isObject).map((t) => ({
591
- label: typeof t.content === 'string' ? t.content : String(t.content ?? ''),
592
- status: normalizeStatus(t.status),
593
- }))
594
- const completed = items.filter((i) => i.status === 'completed').length
595
- const inProgress = items.filter((i) => i.status === 'in_progress').length
596
- return { completed, inProgress, total: items.length, items }
597
- }
598
-
599
- function normalizeStatus(status: unknown): 'pending' | 'in_progress' | 'completed' {
600
- if (status === 'completed') return 'completed'
601
- if (status === 'in_progress') return 'in_progress'
602
- return 'pending'
603
- }
604
-
605
617
  function claudeUsage(raw: unknown): { inputTokens: number; outputTokens: number } | undefined {
606
618
  if (!isObject(raw)) return undefined
607
619
  // Count every input bucket Anthropic bills: fresh input plus BOTH cache reads and
@@ -738,7 +750,7 @@ export async function runCodex(opts: SubscriptionRunOptions): Promise<PiRunOutco
738
750
  },
739
751
  prompt,
740
752
  opts,
741
- codexHome ? { CODEX_HOME: codexHome } : {},
753
+ { ...opts.extraEnv, ...(codexHome ? { CODEX_HOME: codexHome } : {}) },
742
754
  opts.subscriptionToken ? secretsToRedact(opts.subscriptionToken) : [],
743
755
  onEvent,
744
756
  )
@@ -829,9 +841,7 @@ function codexPlanProgress(event: Record<string, unknown>): TodoProgress | undef
829
841
  status: normalizeStatus(s.status),
830
842
  }))
831
843
  if (items.length === 0) return undefined
832
- const completed = items.filter((i) => i.status === 'completed').length
833
- const inProgress = items.filter((i) => i.status === 'in_progress').length
834
- return { completed, inProgress, total: items.length, items }
844
+ return toProgress(items)
835
845
  }
836
846
 
837
847
  /**