Delegate to Claude Code as a lane of its own

24bc97f0b8ad · AtlantisPleb · · parent d1931a576ed2

Delegate to Claude Code as a lane of its own

The third harness, in the shape the first two already have: the coder
can hand a child to the `claude` CLI, so the owner can reach a harness
they already pay for through `openagents coder` and have the work land
in the fleet like any other child.

It runs `--print --output-format stream-json --verbose` and maps the
structured stream into the events the fleet already draws — session,
tool calls, assistant text, tokens, errors — rather than parsing
prose, the same reason the Devin lane moved to ACP.

Two rules from the issue are load-bearing and enforced here. The child
inherits the user's own `claude` environment: this session never
reads, copies, or forwards a foreign credential, and takes no custody
of one. And attribution stays honest — Claude's own configuration
chooses the model, so the lane reports one only when the caller passed
it explicitly, and otherwise says it was not reported rather than
inventing a name.

Children still run as local harness processes; they move onto the
nested-thread ledger when openagents.com#203 lands.

Built by a Devin child through the openagents coder's delegate tool;
766 CLI tests green, and the CLI flags checked against `claude --help`
on this machine rather than assumed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GoYpb8FEmdxVErsv7ABCYi
Co-Authored-By
Claude Fable 5 <noreply@anthropic.com>

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • modified docs/assure-repo/false-green-candidates.v1.json
  • modified docs/assure-repo/surface-inventory.v1.json
  • modified packages/openagents-cli/src/cli.ts
  • modified packages/openagents-cli/src/coder-delegate.ts
  • added packages/openagents-cli/test/coder-delegate-claude.test.ts

Diff

5 files changed, +820 -12

docs/assure-repo/false-green-candidates.v1.json modified +1 -1

@@ -4,7 +4,7 @@

4 4
  "note": "Heuristic false-green LEADS, not findings. A finding requires a demonstrated reproduction (surviving mutation via mutation-runner). Do not treat a candidate as a confirmed false green. Coverage-theater leads may include tests that delegate their assertion to a custom helper the classifier does not recognise; verify before acting.",
5 5
  "sourceDigest": "sha256:dd810dd48c5bdbc9becd7fcc01dd41a4ca2abf0b2d6f6a545907247f6e3e8361",
6 6
  "summary": {
7
    "filesScanned": 2465,
7
    "filesScanned": 2466,
8 8
    "candidateCount": 16,
9 9
    "byMode": {
10 10
      "false_green_coverage_theater": 15,
docs/assure-repo/surface-inventory.v1.json modified +2 -2

@@ -1,7 +1,7 @@

1 1
{
2 2
  "schemaVersion": "1",
3 3
  "repository": "OpenAgentsInc/openagents",
4
  "sourceDigest": "sha256:d54f284f2e5b93477ae762b1e4ca044f5be9959a7496e1e3fb4933e29b74c68b",
4
  "sourceDigest": "sha256:2c21c17d2d8df32e49231085ac25061a9ed63bac9c4b0aae149a6904dd2972c5",
5 5
  "surfaces": [
6 6
    {
7 7
      "id": "app:@openagentsinc/acceptance-runner",

@@ -1876,7 +1876,7 @@

1876 1876
      "oracles": [
1877 1877
        {
1878 1878
          "type": "test",
1879
          "ref": "packages/openagents-cli (61 tracked test files)"
1879
          "ref": "packages/openagents-cli (62 tracked test files)"
1880 1880
        },
1881 1881
        {
1882 1882
          "type": "behavior-contract",
packages/openagents-cli/src/cli.ts modified +22 -8

@@ -37,6 +37,7 @@ import type { DelegationOutcome } from "./coder-delegate.js";

37 37
import {
38 38
  CHILD_MODELS,
39 39
  childLaneName,
40
  ClaudeCodeHarness,
40 41
  DelegateFleet,
41 42
  DevinHarness,
42 43
  describePrompt,

@@ -1709,14 +1710,18 @@ async function buildDelegation(options: {

1709 1710
        // it. `fleetFor` refuses the lane rather than falling through to a
1710 1711
        // different agent under the name the caller asked for.
1711 1712
        new SelfHarness({ grant: nonNullGrant(options.grant) })
1712
      : /^devin(:.+)?$/.test(choice)
1713
        ? new DevinHarness(choice.startsWith("devin:") ? { permissionMode: choice.slice(6) } : {})
1714
        : new OpencodeHarness({
1715
            model: choice,
1716
            ...(command === undefined ? {} : { command }),
1717
            ...(namedConfig === undefined ? {} : { configPath: namedConfig }),
1718
            autoApprove: options.autoApprove,
1719
          });
1713
      : /^claude(:.+)?$/.test(choice)
1714
        ? new ClaudeCodeHarness(
1715
            choice.startsWith("claude:") ? { model: choice.slice(7) } : {},
1716
          )
1717
        : /^devin(:.+)?$/.test(choice)
1718
          ? new DevinHarness(choice.startsWith("devin:") ? { permissionMode: choice.slice(6) } : {})
1719
          : new OpencodeHarness({
1720
              model: choice,
1721
              ...(command === undefined ? {} : { command }),
1722
              ...(namedConfig === undefined ? {} : { configPath: namedConfig }),
1723
              autoApprove: options.autoApprove,
1724
            });
1720 1725
1721 1726
    return {
1722 1727
      fleet: new DelegateFleet(registry, harness, {

@@ -1753,6 +1758,15 @@ async function buildDelegation(options: {

1753 1758
  // and the skill that documents it is not documenting a thing that fails.
1754 1759
  const askedFor = named === undefined ? undefined : resolveChildLane(named);
1755 1760
1761
  if (askedFor !== undefined && /^claude(:(.+))?$/.test(askedFor)) {
1762
    const model = /^claude:(.+)$/.exec(askedFor)?.[1];
1763
    const lane = laneFor(model === undefined ? "claude" : `claude:${model}`);
1764
    return {
1765
      delegation: { registry, ...lane, models: CHILD_MODELS, fleetFor },
1766
      close: () => Promise.resolve(),
1767
    };
1768
  }
1769
1756 1770
  if (askedFor !== undefined && /^devin(:(.+))?$/.test(askedFor)) {
1757 1771
    const mode = /^devin:(.+)$/.exec(askedFor)?.[1];
1758 1772
    const lane = laneFor(mode === undefined ? "devin" : `devin:${mode}`);
packages/openagents-cli/src/coder-delegate.ts modified +346 -1

@@ -35,7 +35,7 @@ import { appendFileSync, createWriteStream, mkdirSync } from "node:fs";

35 35
36 36
import { runDevinAcp } from "./coder-devin-acp.js";
37 37
import { tmpdir } from "node:os";
38
import { join } from "node:path";
38
import { dirname, join } from "node:path";
39 39
import type { CoderTaskId, CoderTaskRegistry, CoderToolActivity } from "./coder-tasks.js";
40 40
41 41
/** What the console asks for. One shape whether it wants one child or fifteen. */

@@ -225,6 +225,174 @@ export function parseOpencodeEvent(line: string): DelegateEvent | undefined {

225 225
 * which says nothing about the provider refusal, the missing credential, or the
226 226
 * unreachable endpoint that actually happened.
227 227
 */
228
/**
229
 * Read one line of `claude -p --output-format stream-json --verbose` output.
230
 *
231
 * Claude's stream-json is newline-delimited and shaped for its own SDK, not for
232
 * this fleet. The mapping is deliberately lossy: this fleet only needs to show
233
 * that the child started, what it is doing, what it said, and how many tokens
234
 * it used. Everything else — `thinking` blocks, per-tool results, `rate_limit`
235
 * events, cost in dollars, cache accounting, and message-level `usage` that
236
 * does not carry a complete `input_tokens`/`output_tokens` pair — is ignored.
237
 *
238
 * Mapped:
239
 *   - `system` `init`       -> `session` (session_id only)
240
 *   - `assistant` text      -> `text`
241
 *   - `assistant` tool_use  -> `tool` (callId, name, target from input)
242
 *   - `result` usage        -> `tokens`
243
 *   - `result` is_error     -> `error`
244
 *   - `stream_event` text_delta -> `text`
245
 *   - `stream_event` tool start -> `tool`
246
 *   - `error`               -> `error`
247
 *
248
 * Not mapped:
249
 *   - `thinking` blocks
250
 *   - `user` tool_result messages
251
 *   - `stream_event` thinking_delta / input_json_delta / message_stop
252
 *   - `rate_limit_event`
253
 *   - model, cost, and cache fields when the harness did not request a model
254
 */
255
export function parseClaudeEvent(line: string): DelegateEvent | undefined {
256
  const trimmed = line.trim();
257
  if (trimmed.length === 0 || !trimmed.startsWith("{")) return undefined;
258
259
  let event: Record<string, unknown>;
260
  try {
261
    event = JSON.parse(trimmed) as Record<string, unknown>;
262
  } catch {
263
    return undefined;
264
  }
265
266
  const type = stringField(event, "type");
267
268
  if (type === "system" && stringField(event, "subtype") === "init") {
269
    const sessionId = stringField(event, "session_id");
270
    if (sessionId !== undefined) return { type: "session", sessionId };
271
    return undefined;
272
  }
273
274
  if (type === "assistant") {
275
    const message = isRecord(event["message"]) ? event["message"] : undefined;
276
    const content = Array.isArray(message?.["content"]) ? (message["content"] as unknown[]) : undefined;
277
    if (content !== undefined && content.length > 0) {
278
      const first = isRecord(content[0]) ? content[0] : {};
279
      const blockType = stringField(first, "type");
280
      if (blockType === "text") {
281
        const value = stringField(first, "text");
282
        if (value !== undefined) return { type: "text", value };
283
      }
284
      if (blockType === "tool_use") {
285
        const callId = stringField(first, "id") ?? "tool";
286
        const name = stringField(first, "name") ?? "tool";
287
        const input = isRecord(first["input"]) ? first["input"] : {};
288
        return { type: "tool", callId, name, target: claudeTarget(input) };
289
      }
290
    }
291
292
    const usage = isRecord(message?.["usage"]) ? message["usage"] : undefined;
293
    if (usage !== undefined) {
294
      const input = numberField(usage, "input_tokens");
295
      const output = numberField(usage, "output_tokens");
296
      if (input !== undefined && output !== undefined) return { type: "tokens", input, output };
297
    }
298
299
    return undefined;
300
  }
301
302
  if (type === "result") {
303
    if (event["is_error"] === true) {
304
      const message =
305
        stringField(event, "error") ??
306
        stringField(event, "result") ??
307
        "the child agent reported an error";
308
      return { type: "error", message };
309
    }
310
311
    const usage = isRecord(event["usage"]) ? event["usage"] : undefined;
312
    if (usage !== undefined) {
313
      const input = numberField(usage, "input_tokens");
314
      const output = numberField(usage, "output_tokens");
315
      if (input !== undefined && output !== undefined) return { type: "tokens", input, output };
316
    }
317
318
    const resultText = stringField(event, "result");
319
    if (resultText !== undefined && resultText.length > 0) return { type: "text", value: resultText };
320
321
    return undefined;
322
  }
323
324
  if (type === "stream_event") {
325
    const inner = isRecord(event["event"]) ? event["event"] : {};
326
    const eventType = stringField(inner, "type");
327
328
    if (eventType === "content_block_delta") {
329
      const delta = isRecord(inner["delta"]) ? inner["delta"] : {};
330
      const deltaType = stringField(delta, "type");
331
      if (deltaType === "text_delta") {
332
        const value = stringField(delta, "text");
333
        if (value !== undefined && value.length > 0) return { type: "text", value };
334
      }
335
    }
336
337
    if (eventType === "content_block_start") {
338
      const contentBlock = isRecord(inner["content_block"]) ? inner["content_block"] : {};
339
      const blockType = stringField(contentBlock, "type");
340
      if (blockType === "tool_use") {
341
        const callId = stringField(contentBlock, "id") ?? "tool";
342
        const name = stringField(contentBlock, "name") ?? "tool";
343
        const input = isRecord(contentBlock["input"]) ? contentBlock["input"] : {};
344
        return { type: "tool", callId, name, target: claudeTarget(input) };
345
      }
346
      if (blockType === "text") {
347
        const value = stringField(contentBlock, "text");
348
        if (value !== undefined && value.length > 0) return { type: "text", value };
349
      }
350
    }
351
352
    if (eventType === "message_delta") {
353
      const usage = isRecord(inner["usage"]) ? inner["usage"] : undefined;
354
      if (usage !== undefined) {
355
        const input = numberField(usage, "input_tokens");
356
        const output = numberField(usage, "output_tokens");
357
        if (input !== undefined && output !== undefined) return { type: "tokens", input, output };
358
      }
359
    }
360
361
    return undefined;
362
  }
363
364
  if (type === "error") {
365
    const message =
366
      stringField(event, "message") ??
367
      stringField(event, "error") ??
368
      "the child agent reported an error";
369
    return { type: "error", message };
370
  }
371
372
  return undefined;
373
}
374
375
/** The phrase a Claude tool_use block is working on. */
376
function claudeTarget(input: Record<string, unknown>): string | undefined {
377
  for (const key of [
378
    "command",
379
    "file_path",
380
    "path",
381
    "file",
382
    "url",
383
    "pattern",
384
    "query",
385
    "description",
386
    "tool_use_id",
387
    "content",
388
    "text",
389
  ]) {
390
    const value = stringField(input, key);
391
    if (value !== undefined && value.length > 0) return value;
392
  }
393
  return undefined;
394
}
395
228 396
function describeHarnessError(event: Record<string, unknown>): string {
229 397
  const error = isRecord(event["error"]) ? event["error"] : {};
230 398
  const data = isRecord(error["data"]) ? error["data"] : {};

@@ -486,6 +654,170 @@ const DEVIN_MODES: Readonly<Record<string, string>> = {

486 654
 * Resolved against what the harness actually lists, so a name that goes away
487 655
 * falls through to the next rather than failing a fan-out.
488 656
 */
657
export interface ClaudeCodeHarnessOptions {
658
  /** The binary. Defaults to `claude`. */
659
  readonly command?: string | undefined;
660
  /** The model name passed to `--model`. If unset, the harness reports `not reported`. */
661
  readonly model?: string | undefined;
662
  /** The permission mode. Defaults to `auto` for unattended runs. */
663
  readonly permissionMode?: string | undefined;
664
  /** A hard cap on turns. Defaults to 50. */
665
  readonly maxTurns?: number | undefined;
666
  /** Extra environment for the child. */
667
  readonly env?: Readonly<Record<string, string | undefined>> | undefined;
668
}
669
670
/**
671
 * Children run by the Claude Code CLI.
672
 *
673
 * Claude Code's `claude -p --output-format stream-json --verbose` is a one-shot
674
 * headless mode that emits newline-delimited JSON events. This harness drives it
675
 * the same way the fleet drives opencode and Devin: it streams events, writes
676
 * the raw transcript, and can be stopped cleanly.
677
 *
678
 * It inherits the user's `claude` environment and credentials. It does not read,
679
 * copy, or forward any Claude credentials from this process.
680
 */
681
export class ClaudeCodeHarness implements DelegateHarness {
682
  readonly agent = "claude";
683
  readonly model: string;
684
685
  constructor(private readonly options: ClaudeCodeHarnessOptions = {}) {
686
    // Claude's own configuration or the init event may choose the model. Only
687
    // report one the caller explicitly passed.
688
    this.model = options.model ?? "not reported";
689
  }
690
691
  async *run(
692
    input: {
693
      readonly prompt: string;
694
      readonly cwd: string;
695
      readonly transcriptPath: string;
696
      readonly resumeSessionId?: string | undefined;
697
    },
698
    signal: AbortSignal,
699
  ): AsyncIterable<DelegateEvent> {
700
    const command = this.options.command ?? "claude";
701
702
    // `--print` runs the prompt and exits. `--output-format stream-json`
703
    // requires `--verbose`. `--permission-mode auto` keeps a delegated child
704
    // from stopping to ask; `--max-turns` is a guard on runaway cost.
705
    const args = [
706
      "-p",
707
      input.prompt,
708
      "--output-format",
709
      "stream-json",
710
      "--verbose",
711
      "--permission-mode",
712
      this.options.permissionMode ?? "auto",
713
    ];
714
    if (this.options.model !== undefined) {
715
      args.push("--model", this.options.model);
716
    }
717
718
    // Claude Code print mode cannot resume an existing session by id, so a
719
    // resumeSessionId is ignored rather than re-running the prompt on a fresh
720
    // session and duplicating any work already done.
721
    void input.resumeSessionId;
722
723
    mkdirSync(dirname(input.transcriptPath), { recursive: true });
724
    const transcript = createWriteStream(input.transcriptPath, { flags: "a" });
725
726
    const child = spawn(command, args, {
727
      cwd: input.cwd,
728
      env: { ...process.env, ...this.options.env },
729
      stdio: ["ignore", "pipe", "pipe"],
730
      detached: true,
731
    });
732
733
    const queue: DelegateEvent[] = [];
734
    let notify: (() => void) | undefined;
735
    const wake = () => {
736
      notify?.();
737
      notify = undefined;
738
    };
739
740
    let stderr = "";
741
    let stdout = "";
742
    let pending = "";
743
    let reported: string | undefined;
744
    let exited = false;
745
    let failure: string | undefined;
746
747
    const onAbort = () => {
748
      killTree(child, "SIGTERM");
749
      const grace = setTimeout(() => killTree(child, "SIGKILL"), KILL_GRACE_MS);
750
      grace.unref();
751
      child.once("close", () => clearTimeout(grace));
752
    };
753
    signal.addEventListener("abort", onAbort, { once: true });
754
755
    child.stdout.setEncoding("utf8");
756
    child.stdout.on("data", (chunk: string) => {
757
      transcript.write(chunk);
758
      stdout = `${stdout}${chunk}`.slice(-4000);
759
      pending += chunk;
760
      let newline = pending.indexOf("\n");
761
      while (newline >= 0) {
762
        const line = pending.slice(0, newline);
763
        pending = pending.slice(newline + 1);
764
        const event = parseClaudeEvent(line);
765
        if (event !== undefined) {
766
          if (event.type === "error") reported = event.message;
767
          queue.push(event);
768
        }
769
        newline = pending.indexOf("\n");
770
      }
771
      wake();
772
    });
773
774
    child.stderr.setEncoding("utf8");
775
    child.stderr.on("data", (chunk: string) => {
776
      stderr = `${stderr}${chunk}`.slice(-4000);
777
    });
778
779
    child.on("error", (cause: Error) => {
780
      failure =
781
        (cause as NodeJS.ErrnoException).code === "ENOENT"
782
          ? `The \`${command}\` harness is not on the path.`
783
          : cause.message;
784
      exited = true;
785
      wake();
786
    });
787
788
    child.on("close", (code) => {
789
      const trailing = parseClaudeEvent(pending);
790
      if (trailing !== undefined) {
791
        if (trailing.type === "error") reported = trailing.message;
792
        queue.push(trailing);
793
      }
794
      if (failure === undefined && code !== 0 && !signal.aborted) {
795
        failure = reported ?? describeExit(code, stderr, stdout);
796
      }
797
      exited = true;
798
      wake();
799
    });
800
801
    try {
802
      while (true) {
803
        while (queue.length > 0) {
804
          const event = queue.shift();
805
          if (event !== undefined) yield event;
806
        }
807
        if (exited) break;
808
        await new Promise<void>((resolve) => {
809
          notify = resolve;
810
        });
811
      }
812
    } finally {
813
      signal.removeEventListener("abort", onAbort);
814
      transcript.end();
815
    }
816
817
    if (failure !== undefined) throw new Error(failure);
818
  }
819
}
820
489 821
export const FREE_CHILD_MODELS: ReadonlyArray<string> = [
490 822
  // Ox Alpha, free and unlimited while it lasts. The slug says neither `ox`
491 823
  // nor `alpha`: opencode's own normalization maps `x-preview-f` to `ox-alpha`

@@ -526,6 +858,7 @@ export const CHILD_LANE_ALIASES: Readonly<Record<string, string>> = {

526 858
  "ox-alpha": SELF_CHILD_LANE,
527 859
  [SELF_CHILD_LANE]: SELF_CHILD_LANE,
528 860
  gemini: SELF_CHILD_LANE,
861
  claude: "claude",
529 862
};
530 863
531 864
/**

@@ -594,6 +927,16 @@ export const CHILD_LANES: ReadonlyArray<ChildLane> = [

594 927
    served: "Devin, on its own credentials — it spends nothing of this account's",
595 928
    bestFor: "straightforward engineering with a clear shape: a named fix, a test, a migration",
596 929
  },
930
  {
931
    name: "claude",
932
    harness: "claude (the Claude Code CLI, its own tools)",
933
    // Claude Code picks its own model when none is passed, and this lane does
934
    // not require one. The init event may report a model, but the harness only
935
    // advertises one the caller asked for.
936
    model: "not reported",
937
    served: "Anthropic via Claude Code, on this machine's Claude credentials",
938
    bestFor: "work that needs Claude's toolset and tolerates its own cost boundary",
939
  },
597 940
];
598 941
599 942
/** One lane as a line a model can read. */

@@ -607,6 +950,7 @@ export const CHILD_MODELS: ReadonlyArray<string> = [

607 950
  ...new Set(Object.keys(CHILD_LANE_ALIASES)),
608 951
  ...FREE_CHILD_MODELS,
609 952
  "devin",
953
  "claude",
610 954
];
611 955
612 956
/**

@@ -622,6 +966,7 @@ export const childLaneName = (lane: string): string =>

622 966
export const resolveChildLane = (name: string): string | undefined => {
623 967
  const asked = name.trim();
624 968
  if (asked.length === 0) return undefined;
969
  if (/^claude(:.+)?$/.test(asked)) return asked;
625 970
  if (/^devin(:.+)?$/.test(asked)) return asked;
626 971
  const aliased = CHILD_LANE_ALIASES[asked];
627 972
  if (aliased !== undefined) return aliased;
packages/openagents-cli/test/coder-delegate-claude.test.ts added +449

@@ -0,0 +1,449 @@

1
import { chmodSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
2
import { tmpdir } from "node:os";
3
import { join } from "node:path";
4
import { describe, expect, it } from "vitest";
5
6
import {
7
  ClaudeCodeHarness,
8
  parseClaudeEvent,
9
  type DelegateEvent,
10
} from "../src/coder-delegate.js";
11
12
/**
13
 * A stand-in for `claude -p --output-format stream-json`, so the tests cost
14
 * nothing and never call out.
15
 *
16
 * It emits the JSONL lines the harness expects and, optionally, records the
17
 * arguments it was called with before exiting.
18
 */
19
const fakeClaude = (options: {
20
  readonly lines?: ReadonlyArray<string>;
21
  readonly exitCode?: number;
22
  readonly hang?: boolean;
23
  readonly recordArgsTo?: string;
24
} = {}): string => {
25
  const directory = mkdtempSync(join(tmpdir(), "claude-code-"));
26
  const script = join(directory, "claude.mjs");
27
28
  writeFileSync(
29
    script,
30
    `
31
import { appendFileSync } from "node:fs";
32
const recordTo = ${JSON.stringify(options.recordArgsTo ?? null)};
33
if (recordTo) appendFileSync(recordTo, process.argv.slice(2).join(" ") + "\\n");
34
if (${options.hang === true}) { setInterval(() => {}, 1000); }
35
${(options.lines ?? [])
36
  .map((line) => `process.stdout.write(${JSON.stringify(`${line}\n`)});`)
37
  .join("\n")}
38
${options.hang === true ? "" : `process.exit(${options.exitCode ?? 0});`}
39
`,
40
  );
41
42
  const shim = join(directory, "claude-stub");
43
  writeFileSync(shim, `#!/bin/sh\nexec ${process.execPath} ${script} "$@"\n`);
44
  chmodSync(shim, 0o755);
45
  return shim;
46
};
47
48
const collect = async (
49
  harness: ClaudeCodeHarness,
50
  cwd = process.cwd(),
51
): Promise<ReadonlyArray<DelegateEvent>> => {
52
  const events: DelegateEvent[] = [];
53
  const transcriptPath = join(mkdtempSync(join(tmpdir(), "claude-t-")), "child.jsonl");
54
  for await (const event of harness.run(
55
    { prompt: "do the thing", cwd, transcriptPath },
56
    new AbortController().signal,
57
  )) {
58
    events.push(event);
59
  }
60
  return events;
61
};
62
63
describe("parseClaudeEvent", () => {
64
  it("ignores blank lines, prose, and malformed JSON", () => {
65
    expect(parseClaudeEvent("")).toBeUndefined();
66
    expect(parseClaudeEvent("starting…")).toBeUndefined();
67
    expect(parseClaudeEvent('{"type":"future"}')).toBeUndefined();
68
    expect(parseClaudeEvent('{"type":"assistan')).toBeUndefined();
69
  });
70
71
  it("reads a system init as a session", () => {
72
    expect(
73
      parseClaudeEvent(
74
        JSON.stringify({
75
          type: "system",
76
          subtype: "init",
77
          session_id: "sess_01",
78
          model: "sonnet",
79
          tools: ["Bash"],
80
        }),
81
      ),
82
    ).toEqual({ type: "session", sessionId: "sess_01" });
83
  });
84
85
  it("reads an assistant tool_use block", () => {
86
    const line = JSON.stringify({
87
      type: "assistant",
88
      session_id: "sess_01",
89
      message: {
90
        id: "msg_1",
91
        role: "assistant",
92
        content: [
93
          { type: "tool_use", id: "toolu_1", name: "Bash", input: { command: "ls -la" } },
94
        ],
95
      },
96
    });
97
    expect(parseClaudeEvent(line)).toEqual({
98
      type: "tool",
99
      callId: "toolu_1",
100
      name: "Bash",
101
      target: "ls -la",
102
    });
103
  });
104
105
  it("reads an assistant text block", () => {
106
    expect(
107
      parseClaudeEvent(
108
        JSON.stringify({
109
          type: "assistant",
110
          session_id: "sess_01",
111
          message: {
112
            id: "msg_2",
113
            role: "assistant",
114
            content: [{ type: "text", text: "Done." }],
115
          },
116
        }),
117
      ),
118
    ).toEqual({ type: "text", value: "Done." });
119
  });
120
121
  it("reads token usage from a result", () => {
122
    expect(
123
      parseClaudeEvent(
124
        JSON.stringify({
125
          type: "result",
126
          subtype: "success",
127
          session_id: "sess_01",
128
          result: "All done.",
129
          usage: { input_tokens: 150, output_tokens: 30 },
130
        }),
131
      ),
132
    ).toEqual({ type: "tokens", input: 150, output: 30 });
133
  });
134
135
  it("reads a result error as an error", () => {
136
    expect(
137
      parseClaudeEvent(
138
        JSON.stringify({
139
          type: "result",
140
          subtype: "error",
141
          is_error: true,
142
          error: "Permission denied",
143
        }),
144
      ),
145
    ).toEqual({ type: "error", message: "Permission denied" });
146
  });
147
148
  it("reads a stream_event text delta", () => {
149
    expect(
150
      parseClaudeEvent(
151
        JSON.stringify({
152
          type: "stream_event",
153
          event: { type: "content_block_delta", delta: { type: "text_delta", text: "hello" } },
154
        }),
155
      ),
156
    ).toEqual({ type: "text", value: "hello" });
157
  });
158
159
  it("reads a stream_event tool start", () => {
160
    const line = JSON.stringify({
161
      type: "stream_event",
162
      event: {
163
        type: "content_block_start",
164
        content_block: { type: "tool_use", id: "toolu_2", name: "Read", input: { file_path: "/tmp/x" } },
165
      },
166
    });
167
    expect(parseClaudeEvent(line)).toEqual({
168
      type: "tool",
169
      callId: "toolu_2",
170
      name: "Read",
171
      target: "/tmp/x",
172
    });
173
  });
174
});
175
176
describe("running children on the Claude Code CLI", () => {
177
  it("names itself in the fleet, so a Claude child is not mistaken for opencode", () => {
178
    const harness = new ClaudeCodeHarness();
179
    expect(harness.agent).toBe("claude");
180
    expect(harness.model).toBe("not reported");
181
  });
182
183
  it("reports the child's answer as text", async () => {
184
    const events = await collect(
185
      new ClaudeCodeHarness({
186
        command: fakeClaude({
187
          lines: [
188
            JSON.stringify({
189
              type: "system",
190
              subtype: "init",
191
              session_id: "sess_01",
192
              tools: ["Bash"],
193
            }),
194
            JSON.stringify({
195
              type: "assistant",
196
              session_id: "sess_01",
197
              message: {
198
                id: "msg_1",
199
                role: "assistant",
200
                content: [{ type: "text", text: "PONG" }],
201
              },
202
            }),
203
            JSON.stringify({
204
              type: "result",
205
              subtype: "success",
206
              session_id: "sess_01",
207
              result: "PONG",
208
              usage: { input_tokens: 10, output_tokens: 1 },
209
            }),
210
          ],
211
        }),
212
      }),
213
    );
214
215
    expect(events.at(-2)).toEqual({ type: "text", value: "PONG" });
216
    expect(events.at(-1)).toEqual({ type: "tokens", input: 10, output: 1 });
217
  });
218
219
  it("reports what the child is doing while it does it", async () => {
220
    const events = await collect(
221
      new ClaudeCodeHarness({
222
        command: fakeClaude({
223
          lines: [
224
            JSON.stringify({
225
              type: "system",
226
              subtype: "init",
227
              session_id: "sess_01",
228
              tools: ["Bash", "Read"],
229
            }),
230
            JSON.stringify({
231
              type: "assistant",
232
              session_id: "sess_01",
233
              message: {
234
                id: "msg_1",
235
                role: "assistant",
236
                content: [
237
                  { type: "tool_use", id: "toolu_1", name: "Bash", input: { command: "ls -la" } },
238
                ],
239
              },
240
            }),
241
            JSON.stringify({
242
              type: "result",
243
              subtype: "success",
244
              session_id: "sess_01",
245
              result: "Done.",
246
              usage: { input_tokens: 100, output_tokens: 20 },
247
            }),
248
          ],
249
        }),
250
      }),
251
    );
252
253
    expect(events).toContainEqual({
254
      type: "tool",
255
      callId: "toolu_1",
256
      name: "Bash",
257
      target: "ls -la",
258
    });
259
    expect(events).toContainEqual({ type: "tokens", input: 100, output: 20 });
260
  });
261
262
  it("reports the session, so a retry can resume it", async () => {
263
    const events = await collect(
264
      new ClaudeCodeHarness({
265
        command: fakeClaude({
266
          lines: [
267
            JSON.stringify({
268
              type: "system",
269
              subtype: "init",
270
              session_id: "sess_01",
271
              tools: ["Bash"],
272
            }),
273
            JSON.stringify({
274
              type: "result",
275
              subtype: "success",
276
              session_id: "sess_01",
277
              result: "ok",
278
              usage: { input_tokens: 5, output_tokens: 1 },
279
            }),
280
          ],
281
        }),
282
      }),
283
    );
284
285
    expect(events[0]).toEqual({ type: "session", sessionId: "sess_01" });
286
  });
287
288
  it("writes the raw event stream to the transcript as it arrives", async () => {
289
    const transcriptPath = join(mkdtempSync(join(tmpdir(), "claude-t-")), "child.jsonl");
290
    const harness = new ClaudeCodeHarness({
291
      command: fakeClaude({
292
        lines: [
293
          JSON.stringify({
294
            type: "assistant",
295
            session_id: "sess_01",
296
            message: {
297
              id: "msg_1",
298
              role: "assistant",
299
              content: [{ type: "text", text: "ok" }],
300
            },
301
          }),
302
        ],
303
      }),
304
    });
305
306
    for await (const _event of harness.run(
307
      { prompt: "x", cwd: process.cwd(), transcriptPath },
308
      new AbortController().signal,
309
    )) {
310
      void _event;
311
    }
312
313
    const written = readFileSync(transcriptPath, "utf8");
314
    expect(written).toContain("ok");
315
  });
316
317
  it("uses print, stream-json, verbose, and a permission mode for unattended runs", async () => {
318
    const log = join(mkdtempSync(join(tmpdir(), "claude-args-")), "log");
319
    await collect(
320
      new ClaudeCodeHarness({
321
        command: fakeClaude({
322
          recordArgsTo: log,
323
          exitCode: 0,
324
          lines: [
325
            JSON.stringify({
326
              type: "result",
327
              subtype: "success",
328
              session_id: "sess_01",
329
              result: "ok",
330
              usage: { input_tokens: 1, output_tokens: 1 },
331
            }),
332
          ],
333
        }),
334
      }),
335
    );
336
337
    const said = readFileSync(log, "utf8");
338
    expect(said).toContain("-p");
339
    expect(said).toContain("--output-format stream-json");
340
    expect(said).toContain("--verbose");
341
    expect(said).toContain("--permission-mode auto");
342
  });
343
344
  it("passes the model through when the lane names one", async () => {
345
    const log = join(mkdtempSync(join(tmpdir(), "claude-args-")), "log");
346
    const harness = new ClaudeCodeHarness({
347
      command: fakeClaude({
348
        recordArgsTo: log,
349
        exitCode: 0,
350
        lines: [
351
          JSON.stringify({
352
            type: "result",
353
            subtype: "success",
354
            session_id: "sess_01",
355
            result: "ok",
356
            usage: { input_tokens: 1, output_tokens: 1 },
357
          }),
358
        ],
359
      }),
360
      model: "fable",
361
    });
362
363
    await collect(harness);
364
365
    expect(harness.model).toBe("fable");
366
    expect(readFileSync(log, "utf8")).toContain("--model fable");
367
  });
368
369
  it("carries the prompt through as the first positional argument", async () => {
370
    const log = join(mkdtempSync(join(tmpdir(), "claude-args-")), "log");
371
    await collect(
372
      new ClaudeCodeHarness({
373
        command: fakeClaude({
374
          recordArgsTo: log,
375
          exitCode: 0,
376
          lines: [
377
            JSON.stringify({
378
              type: "result",
379
              subtype: "success",
380
              session_id: "sess_01",
381
              result: "ok",
382
              usage: { input_tokens: 1, output_tokens: 1 },
383
            }),
384
          ],
385
        }),
386
      }),
387
    );
388
389
    const said = readFileSync(log, "utf8");
390
    expect(said).toContain("-p do the thing");
391
  });
392
393
  it("reports a Claude result error as a failed child", async () => {
394
    const harness = new ClaudeCodeHarness({
395
      command: fakeClaude({
396
        lines: [
397
          JSON.stringify({
398
            type: "result",
399
            subtype: "error",
400
            is_error: true,
401
            error: "the model provider is unavailable",
402
          }),
403
        ],
404
        exitCode: 1,
405
      }),
406
    });
407
408
    await expect(collect(harness)).rejects.toThrow(/model provider is unavailable/);
409
  });
410
411
  it("throws when the binary is not on PATH, rather than reporting an empty child", async () => {
412
    await expect(
413
      collect(new ClaudeCodeHarness({ command: "claude-does-not-exist" })),
414
    ).rejects.toThrow("not on the path");
415
  });
416
417
  it("stops when the fleet is stopped", async () => {
418
    const harness = new ClaudeCodeHarness({
419
      command: fakeClaude({
420
        hang: true,
421
        lines: [
422
          JSON.stringify({
423
            type: "system",
424
            subtype: "init",
425
            session_id: "sess_01",
426
            tools: ["Bash"],
427
          }),
428
        ],
429
      }),
430
    });
431
    const controller = new AbortController();
432
    const events: DelegateEvent[] = [];
433
    const transcriptPath = join(mkdtempSync(join(tmpdir(), "claude-t-")), "child.jsonl");
434
435
    const running = (async () => {
436
      for await (const event of harness.run(
437
        { prompt: "x", cwd: process.cwd(), transcriptPath },
438
        controller.signal,
439
      )) {
440
        events.push(event);
441
      }
442
    })();
443
444
    controller.abort();
445
    await running;
446
447
    expect(events.every((event) => event.type !== "text")).toBe(true);
448
  });
449
});

This page updates live while a promote is in flight · changelog