@zgeoff/atc 3.4.0 → 3.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/package.json +2 -2
  2. package/src/agents/build-agent-adapters.ts +21 -3
  3. package/src/agents/claude-adapter.ts +4 -3
  4. package/src/agents/codex-adapter.ts +20 -0
  5. package/src/agents/gateway-adapter.ts +2 -2
  6. package/src/agents/plan-claude-line-input.ts +44 -0
  7. package/src/agents/render-sdk-message.ts +57 -0
  8. package/src/agents/start-claude-headless-run.ts +2 -57
  9. package/src/agents/with-claude-config-lock.ts +28 -15
  10. package/src/client/boot-daemon.ts +75 -25
  11. package/src/client/resolve-workspace-root.ts +10 -5
  12. package/src/daemon/session-runtime.ts +0 -4
  13. package/src/gateway.ts +2 -179
  14. package/src/mcp/mcp-tools.ts +1 -1
  15. package/src/mcp-http-server.ts +13 -10
  16. package/src/run-gateway-cli.ts +327 -0
  17. package/src/run-gateway.ts +49 -18
  18. package/src/sources/github/run-gh.ts +9 -0
  19. package/src/test-utils/build-mock-execution-target.ts +25 -0
  20. package/src/test-utils/build-mock-imp-session-request.ts +53 -0
  21. package/src/test-utils/build-mock-message-record.ts +7 -3
  22. package/src/test-utils/build-mock-stored-event.ts +20 -0
  23. package/src/test-utils/build-mock-stored-row.ts +23 -0
  24. package/src/test-utils/build-stub-brokered-gateway-adapter.ts +50 -0
  25. package/src/test-utils/build-stub-held-gh.ts +5 -7
  26. package/src/test-utils/build-stub-held-zoxide.ts +4 -6
  27. package/src/test-utils/build-stub-mcp-stdio-server.ts +20 -0
  28. package/src/test-utils/build-stub-recording-filter.ts +9 -0
  29. package/src/test-utils/build-stub-recording-git.ts +8 -0
  30. package/src/test-utils/create-git-fixture.ts +5 -8
  31. package/src/test-utils/create-stub-claude.ts +3 -1
  32. package/src/test-utils/create-stub-echo-claude.ts +21 -0
  33. package/src/test-utils/{build-stub-imp-port.ts → create-stub-imp-port.ts} +39 -5
  34. package/src/test-utils/create-stub-recording-codex.ts +20 -0
  35. package/src/test-utils/create-stub-shared-server-codex.ts +56 -0
  36. package/src/test-utils/create-stub-systemd.ts +10 -4
  37. package/src/test-utils/get-only-imp-name.ts +2 -2
  38. package/src/test-utils/register-test-cleanup.ts +63 -0
  39. package/src/test-utils/run-mcp-authorization.ts +7 -4
  40. package/src/test-utils/{run-legacy-daemon.ts → run-stub-legacy-daemon.ts} +2 -2
  41. package/src/test-utils/setup-mcp-home.ts +24 -13
  42. package/src/test-utils/setup-mcp-http.ts +15 -7
  43. package/src/test-utils/setup-temp-dir.ts +7 -6
  44. package/src/test-utils/start-cut-proxy.ts +6 -2
  45. package/src/test-utils/start-daemon-process.ts +25 -24
  46. package/src/test-utils/start-git-http-server.ts +9 -3
  47. package/src/test-utils/start-mcp-stdio.ts +17 -22
  48. package/src/test-utils/start-stub-answering-listener.ts +65 -0
  49. package/src/test-utils/start-stub-closing-listener.ts +31 -0
  50. package/src/test-utils/start-stub-dropping-daemon.ts +10 -8
  51. package/src/test-utils/start-stub-impd-info.ts +10 -4
  52. package/src/test-utils/start-stub-impd.ts +8 -3
  53. package/src/test-utils/start-stub-legacy-daemon.ts +13 -8
  54. package/src/test-utils/start-stub-mcp-server.ts +7 -2
  55. package/src/test-utils/start-stub-recording-listener.ts +47 -0
  56. package/src/test-utils/start-stub-refusing-listener.ts +9 -4
  57. package/src/test-utils/start-stub-reporter-socket.ts +6 -4
  58. package/src/test-utils/start-stub-restore-daemon.ts +5 -4
  59. package/src/test-utils/start-stub-session-bridge.ts +6 -4
  60. package/src/test-utils/start-stub-silent-server.ts +10 -5
  61. package/src/test-utils/start-stub-source-daemon.ts +6 -4
  62. package/src/test-utils/start-stub-stalled-client.ts +61 -0
  63. package/src/test-utils/start-stub-stalled-listener.ts +13 -9
  64. package/src/test-utils/start-stub-tcp-daemon.ts +9 -4
  65. package/src/test-utils/start-stub-telemetry-collector.ts +9 -3
  66. package/src/test-utils/start-stub-unanswering-listener.ts +41 -0
  67. package/src/test-utils/start-stub-unanswering-unix-listener.ts +38 -0
  68. package/src/test-utils/start-test-daemon.ts +20 -6
  69. package/src/test-utils/start-tui-harness.ts +15 -8
  70. package/src/test-utils/subscribe-to-socket-lines.ts +25 -5
  71. /package/src/test-utils/{run-source-daemon.ts → run-stub-source-daemon.ts} +0 -0
@@ -14,6 +14,35 @@ interface GatewayFlags {
14
14
  readonly stateDir: string;
15
15
  }
16
16
 
17
+ // What the gateway reaches outside itself, the process's own by default:
18
+ // the environment its daemon tokens come from, the console, the exit, and
19
+ // the signal listeners that stop it.
20
+ interface GatewayIO {
21
+ readonly env: Readonly<Record<string, string | undefined>>;
22
+ readonly print: (line: string) => void;
23
+ readonly printError: (line: string) => void;
24
+ readonly exit: (code: number) => void;
25
+ readonly registerSignal: (signal: 'SIGINT' | 'SIGTERM', listener: () => Promise<void>) => void;
26
+ }
27
+
28
+ const PROCESS_IO: GatewayIO = {
29
+ env: process.env,
30
+ print: (line) => {
31
+ console.log(line);
32
+ },
33
+ printError: (line) => {
34
+ console.error(line);
35
+ },
36
+ exit: (code) => {
37
+ process.exit(code);
38
+ },
39
+ registerSignal: (signal, listener) => {
40
+ process.on(signal, () => {
41
+ void listener();
42
+ });
43
+ },
44
+ };
45
+
17
46
  /**
18
47
  * Runs `atc-gateway` in the foreground: serves the MCP tools over HTTP for
19
48
  * the daemons the registry lists, routing every call to one of them over
@@ -23,15 +52,21 @@ interface GatewayFlags {
23
52
  * registry that fails to load, or a server that fails to start, prints the
24
53
  * reason and exits 1.
25
54
  */
26
- export async function runGateway(build: string, flags: GatewayFlags): Promise<void> {
27
- const loaded = loadGatewayRegistry(flags.registryPath, process.env);
55
+ export async function runGateway(
56
+ build: string,
57
+ flags: GatewayFlags,
58
+ io: GatewayIO = PROCESS_IO,
59
+ ): Promise<void> {
60
+ const loaded = loadGatewayRegistry(flags.registryPath, io.env);
28
61
 
29
62
  if (!loaded.ok) {
30
63
  for (const error of loaded.errors) {
31
- console.error(`atc-gateway: ${error}`);
64
+ io.printError(`atc-gateway: ${error}`);
32
65
  }
33
66
 
34
- process.exit(1);
67
+ io.exit(1);
68
+
69
+ return;
35
70
  }
36
71
 
37
72
  mkdirSync(flags.stateDir, { recursive: true });
@@ -59,19 +94,21 @@ export async function runGateway(build: string, flags: GatewayFlags): Promise<vo
59
94
  // The line carries a client's name, so control and format characters
60
95
  // are dropped before it reaches the log.
61
96
  printApproval: (line) => {
62
- console.log(line.replaceAll(/[\p{Cc}\p{Cf}]/gu, ''));
97
+ io.print(line.replaceAll(/[\p{Cc}\p{Cf}]/gu, ''));
63
98
  },
64
99
 
65
100
  // Request lines go to stderr, so stdout keeps the approval lines alone.
66
101
  printRequest: (line) => {
67
- console.error(line);
102
+ io.printError(line);
68
103
  },
69
104
  });
70
105
  } catch (error) {
71
106
  await gateway.stop();
72
107
 
73
- console.error(`atc-gateway: ${error instanceof Error ? error.message : String(error)}`);
74
- process.exit(1);
108
+ io.printError(`atc-gateway: ${error instanceof Error ? error.message : String(error)}`);
109
+ io.exit(1);
110
+
111
+ return;
75
112
  }
76
113
 
77
114
  // The handlers go in before the serving line, so a signal sent once the
@@ -80,16 +117,10 @@ export async function runGateway(build: string, flags: GatewayFlags): Promise<vo
80
117
  await server.stop();
81
118
  await gateway.stop();
82
119
 
83
- process.exit(0);
120
+ io.exit(0);
84
121
  };
85
122
 
86
- process.on('SIGINT', () => {
87
- void stopServing();
88
- });
89
-
90
- process.on('SIGTERM', () => {
91
- void stopServing();
92
- });
93
-
94
- console.log(`atc-gateway: serving ${server.origin}/mcp, listening on ${server.listening}`);
123
+ io.registerSignal('SIGINT', stopServing);
124
+ io.registerSignal('SIGTERM', stopServing);
125
+ io.print(`atc-gateway: serving ${server.origin}/mcp, listening on ${server.listening}`);
95
126
  }
@@ -5,6 +5,12 @@ export interface GHRun {
5
5
  readonly timedOut: boolean;
6
6
  }
7
7
 
8
+ interface GHRunOptions {
9
+ // Called with the pid of the started gh right after it starts; that pid
10
+ // is also its process group's id.
11
+ readonly onSpawn?: (pid: number) => void;
12
+ }
13
+
8
14
  /**
9
15
  * Runs gh with arguments until it exits or the signal aborts, killing its
10
16
  * whole process group on abort, so a wrapper or extension leaves no process
@@ -15,6 +21,7 @@ export async function runGH(
15
21
  bin: string,
16
22
  signal: Readonly<AbortSignal>,
17
23
  args: readonly string[],
24
+ options: GHRunOptions = {},
18
25
  ): Promise<GHRun> {
19
26
  const proc = Bun.spawn([bin, ...args], {
20
27
  env: {
@@ -34,6 +41,8 @@ export async function runGH(
34
41
  detached: true,
35
42
  });
36
43
 
44
+ options.onSpawn?.(proc.pid);
45
+
37
46
  const finished = Promise.all([
38
47
  new Response(proc.stdout).text(),
39
48
  new Response(proc.stderr).text(),
@@ -0,0 +1,25 @@
1
+ import { faker } from '@faker-js/faker';
2
+ import type { ExecutionTarget } from '../daemon/build-execution-targets';
3
+ import { mergeDeep } from './merge-deep';
4
+ import type { MockOverrides } from './mock-overrides';
5
+
6
+ /**
7
+ * A local-pty execution target with an arbitrary id and identity, no
8
+ * options, and no provider, as a target of a kind this daemon lacks has.
9
+ * Overrides merge into fresh defaults at every depth; a provider override
10
+ * replaces the default whole.
11
+ */
12
+ export function buildMockExecutionTarget(
13
+ overrides: MockOverrides<ExecutionTarget, keyof ExecutionTarget> = {},
14
+ ): ExecutionTarget {
15
+ return mergeDeep<ExecutionTarget>(
16
+ {
17
+ id: faker.word.noun(),
18
+ kind: 'local-pty',
19
+ options: {},
20
+ identity: `local-pty:${faker.string.uuid()}`,
21
+ provider: null,
22
+ },
23
+ overrides,
24
+ );
25
+ }
@@ -0,0 +1,53 @@
1
+ import { faker } from '@faker-js/faker';
2
+ import type { ImpSessionRequest } from '../daemon/imp-port';
3
+
4
+ /**
5
+ * The request that starts a process on an imp.
6
+ */
7
+ type StartRequest = Extract<ImpSessionRequest, { readonly kind: 'start' }>;
8
+
9
+ /**
10
+ * The request that attaches to a process already running on an imp.
11
+ */
12
+ type AttachRequest = Extract<ImpSessionRequest, { readonly kind: 'attach' }>;
13
+
14
+ type AttachOverrides = Partial<AttachRequest> & { readonly kind: 'attach' };
15
+
16
+ /**
17
+ * A request that starts `true` on an imp with an empty environment, or, for
18
+ * an override of kind `attach`, one that attaches without waking the imp.
19
+ * Either carries no resume point and no requirements, and an arbitrary imp
20
+ * name, session name, and terminal size; a start's directory is arbitrary
21
+ * too. Each override replaces the default of its field.
22
+ */
23
+ export function buildMockImpSessionRequest(overrides?: Partial<StartRequest>): StartRequest;
24
+ export function buildMockImpSessionRequest(overrides: AttachOverrides): AttachRequest;
25
+
26
+ export function buildMockImpSessionRequest(
27
+ overrides: Partial<StartRequest> | AttachOverrides = {},
28
+ ): ImpSessionRequest {
29
+ const name = `imp-${faker.string.alpha({ length: 6, casing: 'lower' })}`;
30
+ const session = faker.string.uuid();
31
+ const cols = faker.number.int({ min: 20, max: 300 });
32
+ const rows = faker.number.int({ min: 5, max: 100 });
33
+
34
+ return isAttachOverrides(overrides)
35
+ ? { name, session, cols, rows, wake: false, ...overrides }
36
+ : {
37
+ kind: 'start',
38
+ name,
39
+ session,
40
+ argv: ['true'],
41
+ env: {},
42
+ cwd: faker.system.directoryPath(),
43
+ cols,
44
+ rows,
45
+ ...overrides,
46
+ };
47
+ }
48
+
49
+ function isAttachOverrides(
50
+ overrides: Partial<StartRequest> | AttachOverrides,
51
+ ): overrides is AttachOverrides {
52
+ return overrides.kind === 'attach';
53
+ }
@@ -3,13 +3,17 @@ import { toMessageID } from '../shared/to-message-id';
3
3
  import { toSessionID } from '../shared/to-session-id';
4
4
  import type { MessageRecord } from '../store/message-record';
5
5
 
6
+ type MessageRecordOverrides = {
7
+ readonly [K in keyof MessageRecord]?: MessageRecord[K] | undefined;
8
+ };
9
+
6
10
  /**
7
11
  * A message accepted for a session and not yet delivered: a fresh message
8
12
  * id and a fresh atc id, an arbitrary sender, text, and send time, and no
9
13
  * agent session id, delivery, answer, or turn. An override replaces the
10
- * field it names.
14
+ * field it names, and an override of undefined leaves that field out.
11
15
  */
12
- export function buildMockMessageRecord(overrides: Partial<MessageRecord> = {}): MessageRecord {
16
+ export function buildMockMessageRecord(overrides: MessageRecordOverrides = {}): MessageRecord {
13
17
  return {
14
18
  id: toMessageID(faker.string.uuid()),
15
19
  atcID: toSessionID(faker.string.uuid()),
@@ -17,6 +21,6 @@ export function buildMockMessageRecord(overrides: Partial<MessageRecord> = {}):
17
21
  text: faker.lorem.sentence(),
18
22
  status: 'accepted',
19
23
  sentAt: faker.date.past().getTime(),
20
- ...overrides,
24
+ ...Object.fromEntries(Object.entries(overrides).filter(([, value]) => value !== undefined)),
21
25
  };
22
26
  }
@@ -0,0 +1,20 @@
1
+ import { faker } from '@faker-js/faker';
2
+ import { toSessionID } from '../shared/to-session-id';
3
+ import type { StoredEvent } from '../store/state-store';
4
+
5
+ /**
6
+ * A row of the event trail as the store reads it back: a `turn-done` event
7
+ * with an arbitrary row id, time, and fresh atc id, and no agent session
8
+ * id, detail, message, or label. Each override replaces the default of its field.
9
+ */
10
+ export function buildMockStoredEvent(overrides: Partial<StoredEvent> = {}): StoredEvent {
11
+ return {
12
+ id: faker.number.int({ min: 1, max: 1_000_000 }),
13
+ at: faker.date.past().getTime(),
14
+ atcID: toSessionID(faker.string.uuid()),
15
+ agentSessionID: null,
16
+ kind: 'turn-done',
17
+ detail: null,
18
+ ...overrides,
19
+ };
20
+ }
@@ -0,0 +1,23 @@
1
+ import { faker } from '@faker-js/faker';
2
+ import type { StoredRow } from '../read-stored-rows';
3
+ import { mergeDeep } from './merge-deep';
4
+ import type { MockOverrides } from './mock-overrides';
5
+
6
+ /**
7
+ * A fleet row a daemon has stored, for a live session with a fresh id, an
8
+ * arbitrary name, and no known agent session id. Overrides replace the
9
+ * defaults field by field.
10
+ */
11
+ export function buildMockStoredRow(
12
+ overrides: MockOverrides<StoredRow, keyof StoredRow> = {},
13
+ ): StoredRow {
14
+ return mergeDeep<StoredRow>(
15
+ {
16
+ id: faker.string.uuid(),
17
+ name: faker.word.noun(),
18
+ exited: false,
19
+ agentSessionID: null,
20
+ },
21
+ overrides,
22
+ );
23
+ }
@@ -0,0 +1,50 @@
1
+ import type { AgentAdapter } from '../agents/agent-adapter';
2
+ import { buildMockAgentAdapter } from './build-mock-agent-adapter';
3
+
4
+ /**
5
+ * An agent adapter `glm` for daemon tests that only starts behind impd's
6
+ * broker and takes the credential of a GLM gateway from it: the auth
7
+ * profile `glm`, a custom bearer secret `glm` for api.z.ai, behind the
8
+ * gateway at https://api.z.ai/api/anthropic with the placeholder
9
+ * `ANTHROPIC_AUTH_TOKEN`. Every guest spawn sleeps, with the binding's
10
+ * variables, when it has one, and `CLAUDE_CONFIG_DIR` set to
11
+ * `claude-config` in the guest folder. Every other member is the plain
12
+ * agent adapter's.
13
+ */
14
+ export function buildStubBrokeredGatewayAdapter(): AgentAdapter {
15
+ return buildMockAgentAdapter({
16
+ id: 'glm',
17
+ planGuestSpawn: (_opts, guest) => ({
18
+ bin: 'sleep',
19
+ args: ['30'],
20
+ files: {},
21
+ env: { ...guest.auth?.env, CLAUDE_CONFIG_DIR: `${guest.dir}/claude-config` },
22
+ }),
23
+ findAuthSelection: () => ({
24
+ brokerRequired: true,
25
+ gateway: {
26
+ id: 'glm',
27
+ baseURL: 'https://api.z.ai/api/anthropic',
28
+ auth: {
29
+ profiles: ['glm'],
30
+ placeholderEnv: { ANTHROPIC_AUTH_TOKEN: 'imp-broker-placeholder' },
31
+ },
32
+ },
33
+ profiles: new Map([
34
+ [
35
+ 'glm',
36
+ {
37
+ name: 'glm',
38
+ secret: 'glm',
39
+ kind: 'custom',
40
+ host: 'api.z.ai',
41
+ header: 'authorization',
42
+ scheme: 'bearer',
43
+ env: {},
44
+ dependencies: [],
45
+ },
46
+ ],
47
+ ]),
48
+ }),
49
+ });
50
+ }
@@ -1,18 +1,16 @@
1
1
  /**
2
2
  * The script of a stand-in `gh` whose repository listing waits for the
3
- * test. `gh config …` prints `https`. Any other command touches
4
- * `$HOME/gh-held` and waits while `$HOME/gh-hold` exists, then prints one
5
- * repository, `me/dots`, in the JSON shape `gh repo list` prints.
3
+ * test. `gh config …` prints `https`. Any other command waits while
4
+ * `$HOME/gh-hold` exists, touching `$HOME/gh-held` on each pass of the
5
+ * wait, so the marker appears only once the stand-in is held, then prints
6
+ * one repository, `me/dots`, in the JSON shape `gh repo list` prints.
6
7
  */
7
8
  export function buildStubHeldGH(): string {
8
9
  return `#!/bin/sh
9
10
  case "$1" in
10
11
  config) echo https ;;
11
12
  *)
12
- if [ -f "$HOME/gh-hold" ]; then
13
- touch "$HOME/gh-held"
14
- while [ -f "$HOME/gh-hold" ]; do sleep 0.05; done
15
- fi
13
+ while [ -f "$HOME/gh-hold" ]; do touch "$HOME/gh-held"; sleep 0.05; done
16
14
  echo '[{"nameWithOwner":"me/dots","description":"dotfiles","isPrivate":false,"url":"https://github.com/me/dots","sshUrl":"git@github.com:me/dots.git"}]' ;;
17
15
  esac
18
16
  `;
@@ -1,13 +1,11 @@
1
1
  /**
2
2
  * The script of a stand-in `zoxide` that lists no directories, and waits
3
- * for the test first: while `$HOME/zoxide-hold` exists, it touches
4
- * `$HOME/zoxide-held` and waits for the file to go.
3
+ * for the test first: while `$HOME/zoxide-hold` exists, it waits for the
4
+ * file to go, touching `$HOME/zoxide-held` on each pass of the wait, so the
5
+ * marker appears only once the stand-in is held.
5
6
  */
6
7
  export function buildStubHeldZoxide(): string {
7
8
  return `#!/bin/sh
8
- if [ -f "$HOME/zoxide-hold" ]; then
9
- touch "$HOME/zoxide-held"
10
- while [ -f "$HOME/zoxide-hold" ]; do sleep 0.05; done
11
- fi
9
+ while [ -f "$HOME/zoxide-hold" ]; do touch "$HOME/zoxide-held"; sleep 0.05; done
12
10
  `;
13
11
  }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * The script of a stand-in `atc mcp` server on stdio that answers each line
3
+ * it reads with the next of `replies`, printed as given, whether JSON or
4
+ * not, and reads stdin without answering once the replies run out. It
5
+ * exits without a reply when stdin ends before a line arrives. Before
6
+ * it reads anything, it records its pid in the file at its own path with
7
+ * `.pid` appended, moving the file into place so it appears whole.
8
+ */
9
+ export function buildStubMCPStdioServer(replies: readonly string[]): string {
10
+ const answers = replies
11
+ .map((reply) => `read -r _ || exit 0\nprintf '%s\\n' ${toShellLiteral(reply)}\n`)
12
+ .join('');
13
+
14
+ return `#!/usr/bin/env bash\necho $$ > "$0.pid.tmp"\nmv "$0.pid.tmp" "$0.pid"\n${answers}exec cat > /dev/null\n`;
15
+ }
16
+
17
+ // Quotes a value for the shell as one word, whatever it holds.
18
+ function toShellLiteral(value: string): string {
19
+ return `'${value.replaceAll("'", String.raw`'\''`)}'`;
20
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * The script of a stand-in git filter driver that records each run by
3
+ * appending the line `ran` to the record file and passes its input through
4
+ * unchanged, as a smudge filter that changes nothing does, so a test can
5
+ * tell whether git ran a filter the host configured.
6
+ */
7
+ export function buildStubRecordingFilter(record: string): string {
8
+ return `#!/bin/sh\necho ran >> '${record}'\ncat\n`;
9
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * The script of a stand-in `git` that records each run by appending the
3
+ * line `ran` to the record file, then exits 0 without doing anything else,
4
+ * so a test can tell whether the code under test ran git at all.
5
+ */
6
+ export function buildStubRecordingGit(record: string): string {
7
+ return `#!/bin/sh\necho ran >> '${record}'\n`;
8
+ }
@@ -22,17 +22,16 @@ interface GitFixtureConfig {
22
22
  *
23
23
  * The first call in a process builds a template pair under the test home,
24
24
  * and each call copies it, so a fixture costs a copy instead of a run of
25
- * git commands. Disposal removes the directory; hold the result with
26
- * `await using`.
25
+ * git commands. The directory is removed once the current test finishes,
26
+ * so it must run inside a test; disposal removes it sooner, and a second
27
+ * removal does nothing.
27
28
  */
28
29
  export async function createGitFixture(config: GitFixtureConfig = {}) {
29
30
  const env = buildGitEnv(process.env);
30
31
 
31
32
  const template = await resolveTemplate();
32
33
 
33
- await using stack = new AsyncDisposableStack();
34
-
35
- const tmp = stack.use(setupTempDir(config.prefix ?? 'atc-git-fixture-'));
34
+ const tmp = setupTempDir(config.prefix ?? 'atc-git-fixture-');
36
35
  const upstream = join(tmp.dir, 'upstream.git');
37
36
  const work = join(tmp.dir, 'work');
38
37
 
@@ -51,15 +50,13 @@ export async function createGitFixture(config: GitFixtureConfig = {}) {
51
50
  ),
52
51
  );
53
52
 
54
- const owned = stack.move();
55
-
56
53
  return {
57
54
  dir: tmp.dir,
58
55
  env,
59
56
  upstream,
60
57
  work,
61
58
  sha: template.sha,
62
- [Symbol.asyncDispose]: () => owned.disposeAsync(),
59
+ [Symbol.asyncDispose]: tmp[Symbol.asyncDispose],
63
60
  };
64
61
  }
65
62
 
@@ -29,7 +29,8 @@ interface StubClaudeConfig {
29
29
  * first stub to find it removes it; `fake-claude-tap` runs `atc tap` into
30
30
  * `$HOME/tap.jsonl`; `fake-claude-events.jsonl` holds more hook payloads to
31
31
  * report, one per line, after the `Notification`; `fake-claude-exit` exits
32
- * once they are reported.
32
+ * once they are reported; `fake-claude-composer-last` runs the composer once
33
+ * they are reported.
33
34
  */
34
35
  export function createStubClaude(dir: string, config: StubClaudeConfig): string {
35
36
  const atc = config.atc.map((part) => `"${part}"`).join(' ');
@@ -69,6 +70,7 @@ if [ -f "$HOME/fake-claude-events.jsonl" ]; then
69
70
  done < "$HOME/fake-claude-events.jsonl"
70
71
  fi
71
72
  if [ -f "$HOME/fake-claude-exit" ]; then exit 0; fi
73
+ if [ -f "$HOME/fake-claude-composer-last" ]; then exec "${process.execPath}" "${config.composer}"; fi
72
74
  while read -r line; do echo "GOT:$line"; done
73
75
  `,
74
76
  );
@@ -0,0 +1,21 @@
1
+ import { createStubBin } from './create-stub-bin';
2
+
3
+ /**
4
+ * Creates a stand-in for the Claude CLI as `fake-claude` under the directory
5
+ * and returns its path. It prints `UP:<pid>`, then reads one line at a time
6
+ * and prints `GOT:<line>:<pid>` after each, so a test sees which process
7
+ * took its input. A `quit` line ends it with exit code 3.
8
+ */
9
+ export function createStubEchoClaude(dir: string): string {
10
+ return createStubBin(
11
+ dir,
12
+ 'fake-claude',
13
+ `#!/usr/bin/env bash
14
+ echo "UP:$$"
15
+ while read -r line; do
16
+ if [ "$line" = "quit" ]; then exit 3; fi
17
+ echo "GOT:$line:$$"
18
+ done
19
+ `,
20
+ );
21
+ }
@@ -29,6 +29,7 @@ import type {
29
29
  import { ImpPortError } from '../daemon/imp-port-error';
30
30
  import { isImpNameAllowed } from '../daemon/is-imp-name-allowed';
31
31
  import { isBrokerVariable } from '../shared/is-broker-variable';
32
+ import { registerTestCleanup } from './register-test-cleanup';
32
33
 
33
34
  // impd keeps exactly this many bytes of each generation's output.
34
35
  const RING_BYTES = 262_144;
@@ -51,7 +52,7 @@ interface StubCommandHold {
51
52
  }
52
53
 
53
54
  /**
54
- * The impd stand-in the factory below builds, one per call.
55
+ * The impd stand-in the factory below creates, one per call.
55
56
  */
56
57
  class StubImpPort implements ImpPort {
57
58
  // Every port call, in order, as `<call> <imp> [<detail>]`.
@@ -60,6 +61,9 @@ class StubImpPort implements ImpPort {
60
61
  // Every session request the port received, in order.
61
62
  readonly sessionRequests: ImpSessionRequest[] = [];
62
63
 
64
+ // The spec of every imp create the port received, in order.
65
+ readonly createSpecs: ImpCreateSpec[] = [];
66
+
63
67
  features: ImpFeatures = {
64
68
  sessionOffsets: true,
65
69
  leases: true,
@@ -117,6 +121,9 @@ class StubImpPort implements ImpPort {
117
121
  // Feature reads still to fail as an unreachable impd before they answer.
118
122
  private featureFailures = 0;
119
123
 
124
+ // Feature reads that failed as an unreachable impd.
125
+ private failedFeatureReads = 0;
126
+
120
127
  // Session connections whose opening still throws before it returns, as
121
128
  // an invalid authorization header makes the socket's constructor throw.
122
129
  private openFailures = 0;
@@ -153,6 +160,9 @@ class StubImpPort implements ImpPort {
153
160
  readonly done: PromiseWithResolvers<void>;
154
161
  } | null = null;
155
162
 
163
+ // How many commands wait on a command hold.
164
+ private heldCommands = 0;
165
+
156
166
  // Commands whose argv holds this text exit 1 without running, or null.
157
167
  private commandFailure: string | null = null;
158
168
 
@@ -191,6 +201,7 @@ class StubImpPort implements ImpPort {
191
201
 
192
202
  if (this.featureFailures > 0) {
193
203
  this.featureFailures -= 1;
204
+ this.failedFeatureReads += 1;
194
205
 
195
206
  return Promise.reject(new ImpPortError('UNREACHABLE', 'impd did not answer'));
196
207
  }
@@ -294,6 +305,7 @@ class StubImpPort implements ImpPort {
294
305
 
295
306
  createImp(spec: ImpCreateSpec): Promise<ImpView> {
296
307
  this.calls.push(`imps.create ${spec.name}`);
308
+ this.createSpecs.push(spec);
297
309
 
298
310
  if (this.imps.has(spec.name)) {
299
311
  return Promise.reject(
@@ -591,7 +603,11 @@ class StubImpPort implements ImpPort {
591
603
  if (hold !== null && line.includes(hold.match)) {
592
604
  hold.entered.resolve(line);
593
605
 
606
+ this.heldCommands += 1;
607
+
594
608
  await hold.done.promise;
609
+
610
+ this.heldCommands -= 1;
595
611
  }
596
612
 
597
613
  if (this.commandFailure !== null && line.includes(this.commandFailure)) {
@@ -1054,6 +1070,11 @@ class StubImpPort implements ImpPort {
1054
1070
  return { entered: hold.entered.promise, stop, [Symbol.dispose]: stop };
1055
1071
  }
1056
1072
 
1073
+ // How many commands wait for their command hold to stop.
1074
+ countHeldCommands(): number {
1075
+ return this.heldCommands;
1076
+ }
1077
+
1057
1078
  // Lets every command the active hold holds run, and the next at once.
1058
1079
  stopCommandHold(): void {
1059
1080
  const hold = this.commandHold;
@@ -1092,6 +1113,11 @@ class StubImpPort implements ImpPort {
1092
1113
  this.featureFailures = count;
1093
1114
  }
1094
1115
 
1116
+ // How many feature reads failed as an unreachable impd.
1117
+ countFailedFeatureReads(): number {
1118
+ return this.failedFeatureReads;
1119
+ }
1120
+
1095
1121
  /**
1096
1122
  * Refuses the next session request with an impd code and its data, or the
1097
1123
  * message of the refusal when it carries one instead.
@@ -1604,7 +1630,7 @@ class StubImpPort implements ImpPort {
1604
1630
  }
1605
1631
 
1606
1632
  /**
1607
- * Builds an in-process stand-in for impd behind the imp port, calling impd
1633
+ * Creates an in-process stand-in for impd behind the imp port, calling impd
1608
1634
  * as the principal, `token:atc` when absent. Each imp is a set of
1609
1635
  * real `bun-pty` processes on this machine; a sleeping imp stops them with
1610
1636
  * SIGSTOP and a wake continues them, so a memory wake keeps each process
@@ -1622,10 +1648,18 @@ class StubImpPort implements ImpPort {
1622
1648
  * `PRECONDITION_FAILED` and reason `broker_not_ready`, and runs nothing,
1623
1649
  * while the broker fails or the imp holds no grant, when the start sets a
1624
1650
  * broker variable, and when it would join a process that started without
1625
- * the broker required.
1651
+ * the broker required. Once the current test finishes, the stand-in kills
1652
+ * every process and stops every forward it holds, so it must be created inside
1653
+ * a test; disposal does so sooner, and a second disposal does nothing.
1626
1654
  */
1627
- export function buildStubImpPort(principal = 'token:atc'): StubImpPort {
1628
- return new StubImpPort(principal);
1655
+ export function createStubImpPort(principal = 'token:atc'): StubImpPort {
1656
+ const port = new StubImpPort(principal);
1657
+
1658
+ registerTestCleanup(() => {
1659
+ port[Symbol.dispose]();
1660
+ });
1661
+
1662
+ return port;
1629
1663
  }
1630
1664
 
1631
1665
  interface StubLease {
@@ -0,0 +1,20 @@
1
+ import { join } from 'node:path';
2
+ import { createStubBin } from './create-stub-bin';
3
+
4
+ /**
5
+ * Creates a stand-in for the Codex CLI as `fake-codex` under the directory
6
+ * and returns its path. Each start appends one line to `codex-starts.log`
7
+ * under the same directory: the `CODEX_HOME` it started with, then its
8
+ * arguments, separated by spaces. It then stays up without reporting
9
+ * anything. The log exists only once a start has run, so a test proves that
10
+ * no harness started by its absence.
11
+ */
12
+ export function createStubRecordingCodex(dir: string): string {
13
+ const log = join(dir, 'codex-starts.log');
14
+
15
+ return createStubBin(
16
+ dir,
17
+ 'fake-codex',
18
+ `#!/bin/sh\necho "$CODEX_HOME $*" >> '${log}'\nexec sleep 30\n`,
19
+ );
20
+ }