@zgeoff/atc 3.3.1 → 3.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (179) hide show
  1. package/package.json +4 -2
  2. package/src/agents/agent-adapter.ts +10 -0
  3. package/src/agents/build-cli-argv.ts +2 -4
  4. package/src/agents/claude-adapter.ts +13 -3
  5. package/src/agents/codex-adapter.ts +11 -2
  6. package/src/agents/gateway-adapter.ts +20 -8
  7. package/src/agents/grok-adapter.ts +16 -13
  8. package/src/agents/load-claude-config-bundle.ts +6 -6
  9. package/src/agents/print-codex-hook-file.ts +7 -3
  10. package/src/agents/print-grok-hook-file.ts +8 -3
  11. package/src/agents/resolve-agent-home.ts +7 -3
  12. package/src/agents/resolve-claude-global-config-path.ts +4 -4
  13. package/src/agents/update-claude-project-trust.ts +41 -30
  14. package/src/agents/with-claude-config-lock.ts +13 -1
  15. package/src/agents/write-atc-bridge.ts +10 -7
  16. package/src/agents/write-hook-settings.ts +13 -7
  17. package/src/client/boot-daemon.ts +48 -15
  18. package/src/client/dirs.ts +4 -2
  19. package/src/client/index.ts +17 -1
  20. package/src/client/log-client-event.ts +20 -0
  21. package/src/client/spawn-picker.ts +200 -121
  22. package/src/client/ui.ts +7 -7
  23. package/src/clients.ts +41 -11
  24. package/src/daemon/daemon.ts +143 -33
  25. package/src/daemon/event-signal.ts +19 -3
  26. package/src/daemon/make-hook-runner.ts +82 -22
  27. package/src/daemon/permission-registry.ts +11 -6
  28. package/src/daemon/restore-fleet.ts +62 -17
  29. package/src/daemon/run-eject-handoff.ts +20 -2
  30. package/src/daemon/sessions.ts +6 -0
  31. package/src/daemon/start-tcp-listener.ts +12 -10
  32. package/src/federation/daemon-caller.ts +25 -4
  33. package/src/federation/daemon-pool.ts +3 -1
  34. package/src/federation/open-gateway-caller.ts +6 -1
  35. package/src/grants.ts +36 -11
  36. package/src/hook-report.ts +17 -3
  37. package/src/mcp/read-report-texts.ts +11 -7
  38. package/src/mcp/start-mcp-http-server.ts +5 -1
  39. package/src/mcp-http-server.ts +85 -32
  40. package/src/report.ts +21 -3
  41. package/src/run-bridge-tap.ts +36 -7
  42. package/src/run-config-migrate.ts +35 -11
  43. package/src/run-gateway.ts +4 -2
  44. package/src/shared/build-atc-command.ts +7 -3
  45. package/src/shared/bun-sqlite-driver.ts +10 -0
  46. package/src/shared/claim-daemon-lock.ts +10 -4
  47. package/src/shared/collect-dir-roots.ts +6 -6
  48. package/src/shared/collect-hooks.ts +7 -6
  49. package/src/shared/config.ts +19 -9
  50. package/src/shared/system-clock.ts +24 -0
  51. package/src/sources/github/collect-github-repos.ts +1 -1
  52. package/src/sources/github/read-git-protocol.ts +1 -1
  53. package/src/sources/github/run-gh.ts +15 -9
  54. package/src/store/state-store.ts +57 -7
  55. package/src/tap.ts +44 -13
  56. package/src/test-utils/build-mock-agent-adapter.ts +25 -0
  57. package/src/test-utils/build-mock-agent-entry.ts +32 -0
  58. package/src/test-utils/build-mock-auth-binding.ts +28 -0
  59. package/src/test-utils/build-mock-auth-profile.ts +62 -0
  60. package/src/test-utils/build-mock-daemon-record.ts +26 -0
  61. package/src/test-utils/build-mock-fleet-entry.ts +33 -0
  62. package/src/test-utils/build-mock-gateway-config.ts +32 -0
  63. package/src/test-utils/build-mock-imp-identity.ts +18 -0
  64. package/src/test-utils/build-mock-key-binding-claim.ts +34 -0
  65. package/src/test-utils/build-mock-message-record.ts +22 -0
  66. package/src/test-utils/build-mock-mirror-session.ts +42 -0
  67. package/src/test-utils/build-mock-registry-daemon.ts +28 -0
  68. package/src/test-utils/build-mock-session-descriptor.ts +66 -0
  69. package/src/test-utils/build-mock-session.ts +43 -0
  70. package/src/test-utils/build-mock-sortable-session-view.ts +20 -0
  71. package/src/test-utils/build-stub-attention-adapter.ts +22 -0
  72. package/src/test-utils/build-stub-bridge-context.ts +20 -0
  73. package/src/test-utils/build-stub-brokered-agent-adapter.ts +96 -0
  74. package/src/test-utils/build-stub-channel-opener.ts +45 -0
  75. package/src/test-utils/build-stub-claude-headless-run.ts +13 -0
  76. package/src/test-utils/build-stub-client-screens.ts +33 -0
  77. package/src/test-utils/build-stub-clock.ts +67 -0
  78. package/src/test-utils/build-stub-daemon-context.ts +81 -0
  79. package/src/test-utils/build-stub-daemon-requests.ts +103 -0
  80. package/src/test-utils/{fixture-dir-provider.ts → build-stub-dir-provider.ts} +20 -12
  81. package/src/test-utils/build-stub-execution-provider.ts +112 -0
  82. package/src/test-utils/build-stub-fleet-caller.ts +60 -0
  83. package/src/test-utils/build-stub-forking-gh.ts +10 -0
  84. package/src/test-utils/build-stub-gh.ts +50 -0
  85. package/src/test-utils/build-stub-handoff-daemon.ts +11 -0
  86. package/src/test-utils/build-stub-harness-relay.ts +50 -0
  87. package/src/test-utils/build-stub-headless-runner.ts +60 -0
  88. package/src/test-utils/build-stub-held-gh.ts +19 -0
  89. package/src/test-utils/build-stub-held-inbox-source.ts +36 -0
  90. package/src/test-utils/build-stub-held-provider.ts +58 -0
  91. package/src/test-utils/build-stub-held-zoxide.ts +13 -0
  92. package/src/test-utils/build-stub-host-hold.ts +31 -0
  93. package/src/test-utils/build-stub-host-provider.ts +26 -0
  94. package/src/test-utils/{fixture-imp-port.ts → build-stub-imp-port.ts} +119 -63
  95. package/src/test-utils/build-stub-log.ts +15 -0
  96. package/src/test-utils/build-stub-mcp-claude.ts +26 -0
  97. package/src/test-utils/build-stub-mcp-grok.ts +19 -0
  98. package/src/test-utils/build-stub-peer-socket.ts +81 -0
  99. package/src/test-utils/build-stub-picker-host.ts +36 -0
  100. package/src/test-utils/build-stub-proxied-agent-adapter.ts +29 -0
  101. package/src/test-utils/build-stub-pty-provider.ts +53 -0
  102. package/src/test-utils/build-stub-recording-atc.ts +10 -0
  103. package/src/test-utils/build-stub-signed-in-gh.ts +18 -0
  104. package/src/test-utils/build-stub-signed-out-gh.ts +8 -0
  105. package/src/test-utils/build-stub-soft-kill-provider.ts +29 -0
  106. package/src/test-utils/build-stub-targets.ts +58 -0
  107. package/src/test-utils/build-stub-terminal.ts +19 -0
  108. package/src/test-utils/build-stub-timeout-scheduler.ts +48 -0
  109. package/src/test-utils/build-stub-tui-claude.ts +47 -0
  110. package/src/test-utils/build-stub-tui-grok.ts +53 -0
  111. package/src/test-utils/build-stub-wait-clock.ts +23 -0
  112. package/src/test-utils/build-target-options-from-config.ts +37 -0
  113. package/src/test-utils/collect-process-tree.ts +75 -0
  114. package/src/test-utils/create-git-fixture.ts +120 -0
  115. package/src/test-utils/create-migrated-state-db.ts +12 -10
  116. package/src/test-utils/create-stub-bin.ts +19 -0
  117. package/src/test-utils/create-stub-claude.ts +75 -0
  118. package/src/test-utils/create-stub-codex.ts +31 -0
  119. package/src/test-utils/create-stub-composer.ts +65 -0
  120. package/src/test-utils/create-stub-failing-agent-adapter.ts +111 -0
  121. package/src/test-utils/create-stub-grok.ts +44 -0
  122. package/src/test-utils/create-stub-guest-clis.ts +55 -0
  123. package/src/test-utils/create-stub-harness-guest.ts +35 -0
  124. package/src/test-utils/create-stub-recording-claude.ts +20 -0
  125. package/src/test-utils/{setup-fake-systemd.ts → create-stub-systemd.ts} +5 -5
  126. package/src/test-utils/get-gateway-config.ts +3 -3
  127. package/src/test-utils/get-only-effect-ref.ts +21 -0
  128. package/src/test-utils/get-only-imp-name.ts +19 -0
  129. package/src/test-utils/get-records.ts +20 -0
  130. package/src/test-utils/get-string.ts +14 -0
  131. package/src/test-utils/keys.ts +26 -0
  132. package/src/test-utils/merge-deep.ts +36 -0
  133. package/src/test-utils/mock-overrides.ts +13 -0
  134. package/src/test-utils/open-repo-step.ts +24 -0
  135. package/src/test-utils/read-query-plan.ts +22 -0
  136. package/src/test-utils/resolve-atc-command.ts +13 -0
  137. package/src/test-utils/resolve-gateway-command.ts +13 -0
  138. package/src/test-utils/run-atc.ts +56 -0
  139. package/src/test-utils/run-legacy-daemon.ts +27 -5
  140. package/src/test-utils/run-listener-daemon.ts +5 -10
  141. package/src/test-utils/run-source-daemon.ts +2 -2
  142. package/src/test-utils/seed-faker.ts +7 -0
  143. package/src/test-utils/send-lines-before-handshake.ts +35 -0
  144. package/src/test-utils/send-mcp-request.ts +25 -0
  145. package/src/test-utils/setup-mcp-home.ts +99 -0
  146. package/src/test-utils/setup-mcp-http.ts +19 -31
  147. package/src/test-utils/spawn-claude-session.ts +34 -0
  148. package/src/test-utils/spawn-grok-session.ts +39 -0
  149. package/src/test-utils/start-cut-proxy.ts +25 -8
  150. package/src/test-utils/start-daemon-process.ts +192 -0
  151. package/src/test-utils/start-git-http-server.ts +21 -3
  152. package/src/test-utils/start-mcp-stdio.ts +195 -0
  153. package/src/test-utils/start-output-capture.ts +20 -0
  154. package/src/test-utils/start-stub-dropping-daemon.ts +103 -0
  155. package/src/test-utils/start-stub-impd-info.ts +32 -0
  156. package/src/test-utils/start-stub-impd.ts +138 -0
  157. package/src/test-utils/start-stub-legacy-daemon.ts +166 -0
  158. package/src/test-utils/start-stub-mcp-server.ts +50 -0
  159. package/src/test-utils/start-stub-refusing-listener.ts +38 -0
  160. package/src/test-utils/start-stub-reporter-socket.ts +77 -0
  161. package/src/test-utils/start-stub-restore-daemon.ts +58 -0
  162. package/src/test-utils/start-stub-session-bridge.ts +98 -0
  163. package/src/test-utils/start-stub-silent-server.ts +33 -0
  164. package/src/test-utils/start-stub-source-daemon.ts +39 -0
  165. package/src/test-utils/start-stub-stalled-listener.ts +40 -0
  166. package/src/test-utils/start-stub-tap.ts +38 -0
  167. package/src/test-utils/start-stub-tcp-daemon.ts +70 -0
  168. package/src/test-utils/start-stub-telemetry-collector.ts +26 -0
  169. package/src/test-utils/start-test-daemon.ts +215 -0
  170. package/src/test-utils/start-tui-harness.ts +284 -0
  171. package/src/test-utils/subscribe-to-socket-lines.ts +59 -12
  172. package/src/test-utils/try-send-request.ts +29 -0
  173. package/src/test-utils/wait-for-event.ts +41 -0
  174. package/src/test-utils/wait-for.ts +15 -3
  175. package/src/verify-restored-fleet.ts +30 -17
  176. package/src/workspace/check-repository-access.ts +4 -0
  177. package/src/workspace/collect-remote-refs.ts +4 -2
  178. package/src/workspace/run-git.ts +6 -0
  179. package/src/test-utils/start-legacy-daemon.ts +0 -111
@@ -0,0 +1,120 @@
1
+ import { cpSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { $ } from 'bun';
4
+ import { setupTempDir } from './setup-temp-dir';
5
+
6
+ interface GitFixtureConfig {
7
+ // The temp directory's name prefix.
8
+ readonly prefix?: string;
9
+ }
10
+
11
+ /**
12
+ * A bare upstream at `upstream.git` on branch `main` and a work clone of it
13
+ * at `work`, both in a fresh temp directory, holding one pushed commit that
14
+ * adds `README.md`. The clone commits as `atc <atc@example.com>` without
15
+ * signing, and its origin is this fixture's own upstream. `sha` is that
16
+ * commit's id, the same in every fixture. `env` is the process environment
17
+ * without any `GIT_*` variable and with the host's system and global git
18
+ * config switched off: a git hook exports `GIT_DIR` and friends, which
19
+ * would point git at the repository running the hook, and a system-wide Git
20
+ * LFS install adds a pre-push hook that refuses the fixture. Run every git
21
+ * command a test adds with it.
22
+ *
23
+ * The first call in a process builds a template pair under the test home,
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`.
27
+ */
28
+ export async function createGitFixture(config: GitFixtureConfig = {}) {
29
+ const env = buildGitEnv(process.env);
30
+
31
+ const template = await resolveTemplate();
32
+
33
+ await using stack = new AsyncDisposableStack();
34
+
35
+ const tmp = stack.use(setupTempDir(config.prefix ?? 'atc-git-fixture-'));
36
+ const upstream = join(tmp.dir, 'upstream.git');
37
+ const work = join(tmp.dir, 'work');
38
+
39
+ cpSync(template.dir, tmp.dir, { recursive: true });
40
+
41
+ // The copied clone's config still holds the template's upstream as its
42
+ // origin; it is pointed at this fixture's own upstream instead, so a push
43
+ // never reaches the template or another fixture.
44
+ const gitConfig = join(work, '.git', 'config');
45
+
46
+ writeFileSync(
47
+ gitConfig,
48
+ readFileSync(gitConfig, 'utf8').replace(
49
+ `url = ${join(template.dir, 'upstream.git')}`,
50
+ `url = ${upstream}`,
51
+ ),
52
+ );
53
+
54
+ const owned = stack.move();
55
+
56
+ return {
57
+ dir: tmp.dir,
58
+ env,
59
+ upstream,
60
+ work,
61
+ sha: template.sha,
62
+ [Symbol.asyncDispose]: () => owned.disposeAsync(),
63
+ };
64
+ }
65
+
66
+ function buildGitEnv(
67
+ source: Readonly<Record<string, string | undefined>>,
68
+ ): Record<string, string | undefined> {
69
+ return {
70
+ ...Object.fromEntries(Object.entries(source).filter(([name]) => !name.startsWith('GIT_'))),
71
+ GIT_CONFIG_NOSYSTEM: '1',
72
+ GIT_CONFIG_GLOBAL: '/dev/null',
73
+ };
74
+ }
75
+
76
+ interface GitTemplate {
77
+ readonly dir: string;
78
+ readonly sha: string;
79
+ }
80
+
81
+ // The template pair, built once per process.
82
+ let template: Promise<GitTemplate> | null = null;
83
+
84
+ function resolveTemplate(): Promise<GitTemplate> {
85
+ template ??= createTemplate();
86
+
87
+ return template;
88
+ }
89
+
90
+ // The template lives under the test home, which the test script removes
91
+ // when the run ends.
92
+ async function createTemplate(): Promise<GitTemplate> {
93
+ const env = buildGitEnv(process.env);
94
+ const home = process.env['ATC_TEST_HOME'];
95
+
96
+ if (home === undefined) {
97
+ throw new Error('a git fixture needs the test home; run `bun run test`');
98
+ }
99
+
100
+ const dir = mkdtempSync(join(home, 'git-fixture-template-'));
101
+ const upstream = join(dir, 'upstream.git');
102
+ const work = join(dir, 'work');
103
+
104
+ await $`git init --quiet --bare --template= --initial-branch=main ${upstream}`.env(env).quiet();
105
+ await $`git clone --quiet --template= ${upstream} ${work}`.env(env).quiet();
106
+ await $`git config user.name atc`.env(env).cwd(work).quiet();
107
+ await $`git config user.email atc@example.com`.env(env).cwd(work).quiet();
108
+ await $`git config commit.gpgsign false`.env(env).cwd(work).quiet();
109
+ await $`git config tag.gpgsign false`.env(env).cwd(work).quiet();
110
+
111
+ writeFileSync(join(work, 'README.md'), 'hello\n');
112
+
113
+ await $`git add README.md`.env(env).cwd(work).quiet();
114
+ await $`git commit --quiet -m initial`.env(env).cwd(work).quiet();
115
+ await $`git push --quiet origin main`.env(env).cwd(work).quiet();
116
+
117
+ const head = await $`git rev-parse HEAD`.env(env).cwd(work).quiet().text();
118
+
119
+ return { dir, sha: head.trim() };
120
+ }
@@ -11,24 +11,26 @@ let template: Promise<string> | undefined;
11
11
  * that needs a ready store skips the migration ladder. Each copy is a file of
12
12
  * its own; every copy holds the same daemon id, minted when the template was
13
13
  * migrated. The template sits in the test home, which the test script removes
14
- * on exit.
14
+ * on exit, under a name of its own per process, since parallel test processes
15
+ * share that home. It rejects when `ATC_TEST_HOME` is unset, as it is
16
+ * outside the test script.
15
17
  */
16
18
  export async function createMigratedStateDB(dbPath: string): Promise<void> {
17
- template ??= createTemplateStateDB();
18
-
19
- const templatePath = await template;
20
-
21
- copyFileSync(templatePath, dbPath);
22
- }
23
-
24
- async function createTemplateStateDB(): Promise<string> {
25
19
  const home = process.env['ATC_TEST_HOME'];
26
20
 
27
21
  if (home === undefined) {
28
22
  throw new Error('ATC_TEST_HOME is unset; run the tests through `bun run test`');
29
23
  }
30
24
 
31
- const path = join(home, 'migrated-state.db');
25
+ template ??= createTemplateStateDB(home);
26
+
27
+ const templatePath = await template;
28
+
29
+ copyFileSync(templatePath, dbPath);
30
+ }
31
+
32
+ async function createTemplateStateDB(home: string): Promise<string> {
33
+ const path = join(home, `migrated-state-${String(process.pid)}.db`);
32
34
 
33
35
  const store = await StateStore.open(path);
34
36
 
@@ -0,0 +1,19 @@
1
+ import { chmodSync, mkdirSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
4
+ /**
5
+ * Creates a stand-in command-line tool: the script, shebang included, as an
6
+ * executable file under the directory, which it creates when missing.
7
+ * Returns the script's path. Writing over an existing file replaces its
8
+ * content and makes it executable as well, so a test can swap a tool's
9
+ * behaviour mid-run.
10
+ */
11
+ export function createStubBin(dir: string, name: string, script: string): string {
12
+ const path = join(dir, name);
13
+
14
+ mkdirSync(dir, { recursive: true });
15
+ writeFileSync(path, script);
16
+ chmodSync(path, 0o755);
17
+
18
+ return path;
19
+ }
@@ -0,0 +1,75 @@
1
+ import { createStubBin } from './create-stub-bin';
2
+
3
+ interface StubClaudeConfig {
4
+ // The command atc runs as, which the stub's tap and restart go through.
5
+ readonly atc: readonly string[];
6
+
7
+ // The composer script the stub finishes in when the home asks for it.
8
+ readonly composer: string;
9
+ }
10
+
11
+ /**
12
+ * Creates a stand-in for the Claude CLI as `fake-claude` under the
13
+ * directory and returns its path. Like the real CLI, it runs the
14
+ * `SessionStart` hook command from the `--settings` file atc passed it
15
+ * through a shell, feeding each hook its JSON payload on stdin, so a session
16
+ * reports with the command line atc wrote for its agent. It prints its
17
+ * arguments, its TERM, and whether it inherited a parent-session variable,
18
+ * appends its atc session id to `$HOME/fake-claude-starts.log`, reports a
19
+ * `SessionStart` as `fake-1` and a `Notification` asking for permission, then
20
+ * echoes each input line as `GOT:<line>`.
21
+ *
22
+ * Files in `$HOME` steer a scenario: `fake-claude-dies-<id>` exits at once
23
+ * when the stub resumes that agent session; `fake-claude-hold-start` reports
24
+ * nothing and only echoes input; `fake-claude-composer` runs the composer
25
+ * instead; `fake-claude-gate` waits for one input line before it reports,
26
+ * and the first stub to find it removes it; `fake-claude-own-id` reports the
27
+ * atc session id as the agent session; `fake-claude-restart` runs
28
+ * `atc daemon restart` into `$HOME/restart.out` after `SessionStart`, and the
29
+ * first stub to find it removes it; `fake-claude-tap` runs `atc tap` into
30
+ * `$HOME/tap.jsonl`; `fake-claude-events.jsonl` holds more hook payloads to
31
+ * report, one per line, after the `Notification`; `fake-claude-exit` exits
32
+ * once they are reported.
33
+ */
34
+ export function createStubClaude(dir: string, config: StubClaudeConfig): string {
35
+ const atc = config.atc.map((part) => `"${part}"`).join(' ');
36
+
37
+ return createStubBin(
38
+ dir,
39
+ 'fake-claude',
40
+ `#!/usr/bin/env bash
41
+ echo "FAKE_CLAUDE_UP args: $@"
42
+ echo "FAKE_CLAUDE_TERM:[\${TERM-unset}]"
43
+ echo "FAKE_CLAUDE_PARENT:[\${CLAUDE_CODE_ATC_TEST-unset}]"
44
+ echo "$ATC_SESSION_ID" >> "$HOME/fake-claude-starts.log"
45
+ settings=""
46
+ resume=""
47
+ prev=""
48
+ for arg in "$@"; do
49
+ if [ "$prev" = "--settings" ]; then settings="$arg"; fi
50
+ if [ "$prev" = "--resume" ]; then resume="$arg"; fi
51
+ prev="$arg"
52
+ done
53
+ if [ -n "$resume" ] && [ -f "$HOME/fake-claude-dies-$resume" ]; then exit 0; fi
54
+ if [ -f "$HOME/fake-claude-hold-start" ]; then while read -r line; do echo "GOT:$line"; done; exit 0; fi
55
+ if [ -f "$HOME/fake-claude-composer" ]; then exec "${process.execPath}" "${config.composer}"; fi
56
+ hookCommand="$("${process.execPath}" -e 'const s = JSON.parse(require("fs").readFileSync(process.argv.at(-1), "utf8")); console.log(s.hooks.SessionStart[0].hooks[0].command)' "$settings" < /dev/null)"
57
+ hookReport() { sh -c "$hookCommand"; }
58
+ sessionID="fake-1"
59
+ if [ -f "$HOME/fake-claude-own-id" ]; then sessionID="$ATC_SESSION_ID"; fi
60
+ if [ -f "$HOME/fake-claude-gate" ]; then rm "$HOME/fake-claude-gate"; read -r _; fi
61
+ printf '{"hook_event_name":"SessionStart","session_id":"%s","transcript_path":"%s/fake-transcript.jsonl"}' "$sessionID" "$HOME" | hookReport
62
+ if [ -f "$HOME/fake-claude-restart" ]; then rm "$HOME/fake-claude-restart"; ${atc} daemon restart > "$HOME/restart.out" 2>&1; fi
63
+ if [ -f "$HOME/fake-claude-tap" ]; then ${atc} tap --session "$ATC_SESSION_ID" >> "$HOME/tap.jsonl" & fi
64
+ printf '{"hook_event_name":"Notification","session_id":"%s","message":"needs permission"}' "$sessionID" | hookReport
65
+ if [ -f "$HOME/fake-claude-events.jsonl" ]; then
66
+ while IFS= read -r ev; do
67
+ [ -n "$ev" ] || continue
68
+ printf '%s' "$ev" | hookReport
69
+ done < "$HOME/fake-claude-events.jsonl"
70
+ fi
71
+ if [ -f "$HOME/fake-claude-exit" ]; then exit 0; fi
72
+ while read -r line; do echo "GOT:$line"; done
73
+ `,
74
+ );
75
+ }
@@ -0,0 +1,31 @@
1
+ import { createStubBin } from './create-stub-bin';
2
+
3
+ interface StubCodexConfig {
4
+ // The command atc runs as; the stub reports each hook through its
5
+ // `hook-report --agent codex`, as the hook entries atc prints for Codex do.
6
+ readonly atc: readonly string[];
7
+
8
+ // The composer script the stub finishes in.
9
+ readonly composer: string;
10
+ }
11
+
12
+ /**
13
+ * Creates a stand-in for the Codex CLI as `fake-codex` under the directory
14
+ * and returns its path. It prints its arguments, reports a `SessionStart`
15
+ * as `fake-codex-1` with its working directory and a rollout under `$HOME`,
16
+ * then a `Stop` whose last message is `pong`, then runs the composer.
17
+ */
18
+ export function createStubCodex(dir: string, config: StubCodexConfig): string {
19
+ const report = `${config.atc.map((part) => `"${part}"`).join(' ')} hook-report --agent codex`;
20
+
21
+ return createStubBin(
22
+ dir,
23
+ 'fake-codex',
24
+ `#!/usr/bin/env bash
25
+ echo "FAKE_CODEX_UP args: $@"
26
+ printf '{"hook_event_name":"SessionStart","session_id":"fake-codex-1","transcript_path":"%s/fake-rollout.jsonl","cwd":"%s","source":"startup"}' "$HOME" "$PWD" | ${report}
27
+ printf '{"hook_event_name":"Stop","session_id":"fake-codex-1","transcript_path":"%s/fake-rollout.jsonl","last_assistant_message":"pong"}' "$HOME" | ${report}
28
+ exec "${process.execPath}" "${config.composer}"
29
+ `,
30
+ );
31
+ }
@@ -0,0 +1,65 @@
1
+ import { createStubBin } from './create-stub-bin';
2
+
3
+ /**
4
+ * Creates the composer the stub agents finish in, as `fake-composer.js`
5
+ * under the directory, and returns its path; run it with bun. It is modelled
6
+ * on the TUIs Codex and Grok draw: raw mode with bracketed paste on. Each
7
+ * read is one input event batch. A bracketed paste lands in the composer
8
+ * whole, newlines kept; a lone CR outside a paste submits; a lone LF is
9
+ * Ctrl-J and adds a newline; any other read of more than one byte is a paste
10
+ * burst whose line breaks stay in the composer. A read that ends partway
11
+ * into a paste marker holds that part for the next read. It prints
12
+ * `FAKE_COMPOSER_READY` once paste mode is on, every byte received so far as
13
+ * `RECEIVED:<json>` after each read, and each submission as `SUBMIT:<json>`.
14
+ */
15
+ export function createStubComposer(dir: string): string {
16
+ return createStubBin(
17
+ dir,
18
+ 'fake-composer.js',
19
+ String.raw`const OPEN = '\u001B[200~';
20
+ const CLOSE = '\u001B[201~';
21
+ let composer = '';
22
+ let pasting = false;
23
+ let held = '';
24
+ let received = '';
25
+ process.stdin.setRawMode(true);
26
+ process.stdout.write('\u001B[?2004hFAKE_COMPOSER_READY\r\n');
27
+ process.stdin.on('data', (buf) => {
28
+ const chunk = buf.toString('utf8');
29
+ received += chunk;
30
+ process.stdout.write('RECEIVED:' + JSON.stringify(received) + '\r\n');
31
+ let rest = held + chunk;
32
+ held = '';
33
+ const cut = rest.lastIndexOf('\u001B');
34
+ const tail = cut === -1 ? '' : rest.slice(cut);
35
+ if (tail !== '' && tail.length < CLOSE.length && (OPEN.startsWith(tail) || CLOSE.startsWith(tail))) {
36
+ held = tail;
37
+ rest = rest.slice(0, cut);
38
+ }
39
+ while (rest !== '') {
40
+ if (pasting) {
41
+ const end = rest.indexOf(CLOSE);
42
+ composer += (end === -1 ? rest : rest.slice(0, end)).replaceAll('\r', '\n');
43
+ pasting = end === -1;
44
+ rest = end === -1 ? '' : rest.slice(end + CLOSE.length);
45
+ continue;
46
+ }
47
+ if (rest.startsWith(OPEN)) {
48
+ pasting = true;
49
+ rest = rest.slice(OPEN.length);
50
+ continue;
51
+ }
52
+ const next = rest.indexOf(OPEN);
53
+ const plain = next === -1 ? rest : rest.slice(0, next);
54
+ rest = next === -1 ? '' : rest.slice(next);
55
+ if (plain === '\r') {
56
+ process.stdout.write('SUBMIT:' + JSON.stringify(composer) + '\r\n');
57
+ composer = '';
58
+ } else {
59
+ composer += plain.replaceAll('\r', '\n');
60
+ }
61
+ }
62
+ });
63
+ `,
64
+ );
65
+ }
@@ -0,0 +1,111 @@
1
+ import type { AgentAdapter } from '../agents/agent-adapter';
2
+ import { buildMockAgentAdapter } from './build-mock-agent-adapter';
3
+
4
+ interface Plan {
5
+ readonly bin: string;
6
+ readonly args: readonly string[];
7
+ }
8
+
9
+ // A named pipe the first spawn's process writes its pid to once it is ready
10
+ // to be killed, and how long the first failing read waits for that pid.
11
+ interface ReadyPipe {
12
+ readonly path: string;
13
+ readonly timeoutMs: number;
14
+ }
15
+
16
+ interface FailingAgentAdapterConfig {
17
+ // What the first spawn runs, and what every later spawn runs.
18
+ readonly firstPlan: Plan;
19
+ readonly laterPlan: Plan;
20
+
21
+ // How many reads of the headless runner throw once the first spawn is
22
+ // planned: one fails the start after its process started, and a second
23
+ // fails the kill that takes the process back.
24
+ readonly failedReads: number;
25
+
26
+ // The pipe the first failing read waits on. Null fails at once.
27
+ readonly ready: ReadyPipe | null;
28
+ }
29
+
30
+ /**
31
+ * An agent adapter whose first spawn fails after its process has started:
32
+ * the daemon reads the adapter's headless runner once the process runs, and
33
+ * that read throws, as many times as the config holds, while every other
34
+ * read finds no headless runner. Every member but the spawn plan and the
35
+ * headless runner is the mock adapter's.
36
+ *
37
+ * With a ready pipe, the stub makes the pipe at that path, and the first
38
+ * failing read blocks until a process writes its pid there, which the
39
+ * headless runner read is synchronous for. A read that finds no writer
40
+ * within the timeout throws instead of failing the start. `getReadyPID`
41
+ * returns the pid that read took. `countPlans` reads how many spawns the
42
+ * adapter has planned.
43
+ */
44
+ export function createStubFailingAgentAdapter(config: FailingAgentAdapterConfig) {
45
+ let planned = 0;
46
+ let readsToFail = 0;
47
+ let readyPID: number | null = null;
48
+
49
+ if (config.ready !== null) {
50
+ createPipe(config.ready.path);
51
+ }
52
+
53
+ const adapter: AgentAdapter = {
54
+ ...buildMockAgentAdapter(),
55
+ planSpawn: () => {
56
+ planned += 1;
57
+
58
+ if (planned > 1) {
59
+ return { bin: config.laterPlan.bin, args: [...config.laterPlan.args] };
60
+ }
61
+
62
+ readsToFail = config.failedReads;
63
+
64
+ return { bin: config.firstPlan.bin, args: [...config.firstPlan.args] };
65
+ },
66
+ get headlessRunner() {
67
+ if (readsToFail === 0) {
68
+ return null;
69
+ }
70
+
71
+ if (readsToFail === config.failedReads && config.ready !== null) {
72
+ readyPID = readPID(config.ready);
73
+ }
74
+
75
+ readsToFail -= 1;
76
+ throw new Error('adapter failed after the process started');
77
+ },
78
+ };
79
+
80
+ return {
81
+ adapter,
82
+ countPlans: () => planned,
83
+ getReadyPID: (): number => {
84
+ if (readyPID === null) {
85
+ throw new Error('no process has written its pid to the ready pipe');
86
+ }
87
+
88
+ return readyPID;
89
+ },
90
+ };
91
+ }
92
+
93
+ function createPipe(path: string): void {
94
+ const made = Bun.spawnSync(['mkfifo', path]);
95
+
96
+ if (made.exitCode !== 0) {
97
+ throw new Error(`mkfifo could not make ${path} (${made.stderr.toString().trim()})`);
98
+ }
99
+ }
100
+
101
+ // Reading the pipe blocks until a writer opens it and closes it, so the
102
+ // read returns once the process has written its pid.
103
+ function readPID(ready: ReadyPipe): number {
104
+ const read = Bun.spawnSync({ cmd: ['cat', ready.path], timeout: ready.timeoutMs });
105
+
106
+ if (read.exitedDueToTimeout === true) {
107
+ throw new Error(`no process wrote ${ready.path} within ${String(ready.timeoutMs)}ms`);
108
+ }
109
+
110
+ return Number(read.stdout.toString().trim());
111
+ }
@@ -0,0 +1,44 @@
1
+ import { createStubBin } from './create-stub-bin';
2
+
3
+ interface StubGrokConfig {
4
+ // The command atc runs as; the stub reports each hook through its
5
+ // `hook-report --agent grok`, as the hook file atc prints for Grok does.
6
+ readonly atc: readonly string[];
7
+
8
+ // The composer script the stub finishes in.
9
+ readonly composer: string;
10
+ }
11
+
12
+ /**
13
+ * Creates a stand-in for the Grok CLI as `fake-grok` under the directory and
14
+ * returns its path. It prints its arguments, reports a `session_start` as
15
+ * `fake-grok-1` with its working directory and a `notification` asking
16
+ * permission to edit, prints `FAKE_GROK_HOOKS_DONE` once its reports are
17
+ * sent, then runs the composer. Files in `$HOME` steer a scenario:
18
+ * `fake-grok-hold-start` reports nothing and only echoes input lines as
19
+ * `GOT:<line>`; `fake-grok-events.jsonl` holds the hook payloads to report
20
+ * after `session_start` in place of the notification, one per line.
21
+ */
22
+ export function createStubGrok(dir: string, config: StubGrokConfig): string {
23
+ const report = `${config.atc.map((part) => `"${part}"`).join(' ')} hook-report --agent grok`;
24
+
25
+ return createStubBin(
26
+ dir,
27
+ 'fake-grok',
28
+ `#!/usr/bin/env bash
29
+ echo "FAKE_GROK_UP args: $@"
30
+ if [ -f "$HOME/fake-grok-hold-start" ]; then while read -r line; do echo "GOT:$line"; done; exit 0; fi
31
+ printf '{"hookEventName":"session_start","sessionId":"fake-grok-1","cwd":"%s"}' "$PWD" | ${report}
32
+ if [ -f "$HOME/fake-grok-events.jsonl" ]; then
33
+ while IFS= read -r ev; do
34
+ [ -n "$ev" ] || continue
35
+ printf '%s' "$ev" | ${report}
36
+ done < "$HOME/fake-grok-events.jsonl"
37
+ else
38
+ printf '{"hookEventName":"notification","sessionId":"fake-grok-1","notificationType":"permission_prompt","message":"allow edit?"}' | ${report}
39
+ fi
40
+ echo "FAKE_GROK_HOOKS_DONE"
41
+ exec "${process.execPath}" "${config.composer}"
42
+ `,
43
+ );
44
+ }
@@ -0,0 +1,55 @@
1
+ import { join } from 'node:path';
2
+ import { createStubBin } from './create-stub-bin';
3
+
4
+ /**
5
+ * Creates the command-line tools a remote host runs for a session, as
6
+ * scripts under the directory: `atc` runs the atc CLI from this source tree,
7
+ * and `claude` is a stand-in agent that prints `UP:<pid>`, then reads one
8
+ * command per line and prints `GOT:<line>` after each, reporting through
9
+ * that atc:
10
+ *
11
+ * - `start <id>`: a SessionStart for agent session `<id>`, with a
12
+ * transcript at a path only the host holds;
13
+ * - `notify <text>`: a Notification carrying the text;
14
+ * - `nested <id>`: a SessionStart for agent session `<id>` from a Codex
15
+ * harness nested inside the session;
16
+ * - `forge <session>`: a Notification as atc session `<session>` instead
17
+ * of its own;
18
+ * - `tap <file>`: the session's tap, run in the background and printing
19
+ * into the file;
20
+ * - `answer <message> <text>`: the message answered with the text;
21
+ * - `note <text>`: a note labelled `progress`.
22
+ *
23
+ * Returns each tool's path.
24
+ */
25
+ export function createStubGuestCLIs(dir: string) {
26
+ const cliPath = join(import.meta.dir, '..', 'cli.ts');
27
+
28
+ const atc = createStubBin(
29
+ dir,
30
+ 'atc',
31
+ `#!/bin/sh\nexec "${process.execPath}" "${cliPath}" "$@"\n`,
32
+ );
33
+
34
+ const claude = createStubBin(
35
+ dir,
36
+ 'claude',
37
+ `#!/usr/bin/env bash
38
+ echo "UP:$$"
39
+ while read -r line; do
40
+ case "$line" in
41
+ start\\ *) printf '{"hook_event_name":"SessionStart","session_id":"%s","transcript_path":"/guest/only/transcript.jsonl"}' "\${line#start }" | "${atc}" hook-report --agent claude ;;
42
+ notify\\ *) printf '{"hook_event_name":"Notification","message":"%s"}' "\${line#notify }" | "${atc}" hook-report --agent claude ;;
43
+ nested\\ *) printf '{"hook_event_name":"SessionStart","session_id":"%s","source":"startup"}' "\${line#nested }" | "${atc}" hook-report --agent codex ;;
44
+ forge\\ *) echo '{"hook_event_name":"Notification","message":"forged"}' | ATC_SESSION_ID="\${line#forge }" "${atc}" hook-report --agent claude ;;
45
+ tap\\ *) "${atc}" tap --session "$ATC_SESSION_ID" >> "\${line#tap }" 2>&1 & ;;
46
+ answer\\ *) rest="\${line#answer }"; printf '%s' "\${rest#* }" | "${atc}" report answered --messages "\${rest%% *}" ;;
47
+ note\\ *) printf '%s' "\${line#note }" | "${atc}" report note --label progress ;;
48
+ esac
49
+ echo "GOT:$line"
50
+ done
51
+ `,
52
+ );
53
+
54
+ return { atc, claude };
55
+ }
@@ -0,0 +1,35 @@
1
+ import { join } from 'node:path';
2
+ import { createStubBin } from './create-stub-bin';
3
+
4
+ /**
5
+ * Creates a stand-in guest program for a harness to run, under the
6
+ * directory: a script that prints its pid and the terminal size it started
7
+ * at as `UP:<pid> START:<rows> <cols>`, echoes each line it reads as
8
+ * `GOT:<line>`, prints `SIZE:<rows> <cols>` on `size`, and exits 3 on
9
+ * `quit`. On `later` it waits for a line on the named pipe at `burstPath`,
10
+ * then prints 300000 `x` bytes, more than impd's ring keeps, a newline, and
11
+ * `BURST_DONE`. Returns the script's path and the pipe's.
12
+ */
13
+ export function createStubHarnessGuest(dir: string) {
14
+ const burstPath = join(dir, 'burst');
15
+
16
+ Bun.spawnSync(['mkfifo', burstPath]);
17
+
18
+ const path = createStubBin(
19
+ dir,
20
+ 'harness',
21
+ `#!/usr/bin/env bash
22
+ echo "UP:$$ START:$(stty size)"
23
+ while read -r line; do
24
+ if [ "$line" = "later" ]; then
25
+ (cat "${burstPath}" > /dev/null; head -c 300000 /dev/zero | tr '\\0' 'x'; echo; echo "BURST_DONE") &
26
+ fi
27
+ if [ "$line" = "size" ]; then echo "SIZE:$(stty size)"; fi
28
+ if [ "$line" = "quit" ]; then exit 3; fi
29
+ echo "GOT:$line"
30
+ done
31
+ `,
32
+ );
33
+
34
+ return { path, burstPath };
35
+ }
@@ -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 Claude CLI as `fake-claude` under the
6
+ * directory and returns its path. Each start appends its arguments to
7
+ * `claude-starts.log` under the same directory, one per line, ends the
8
+ * start with an empty line, and then stays up without reporting anything,
9
+ * as a session whose agent never speaks does. The log exists only once a
10
+ * start has run, so a test proves that no harness started by its absence.
11
+ */
12
+ export function createStubRecordingClaude(dir: string): string {
13
+ const log = join(dir, 'claude-starts.log');
14
+
15
+ return createStubBin(
16
+ dir,
17
+ 'fake-claude',
18
+ `#!/bin/sh\n{ printf '%s\\n' "$@"; echo; } >> '${log}'\nexec sleep 30\n`,
19
+ );
20
+ }
@@ -2,8 +2,8 @@ import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync
2
2
  import { tmpdir } from 'node:os';
3
3
  import { join } from 'node:path';
4
4
 
5
- interface FakeSystemd {
6
- // The directory that holds the fake `systemctl` and `systemd-run`, to put
5
+ interface StubSystemd {
6
+ // The directory that holds the stand-in `systemctl` and `systemd-run`, to put
7
7
  // first on the PATH of every process that could reach a real one.
8
8
  readonly binDir: string;
9
9
 
@@ -27,7 +27,7 @@ interface FakeSystemd {
27
27
  }
28
28
 
29
29
  /**
30
- * Writes a fake `systemctl` and `systemd-run` that touch no service
30
+ * Writes a stand-in `systemctl` and `systemd-run` that touch no service
31
31
  * manager. Without a MainPID, `systemctl show -p MainPID` prints a pid no
32
32
  * process holds, so no daemon is ever a unit's main process. With
33
33
  * `writeMainPID`, `systemctl restart` stops that pid with SIGTERM and starts
@@ -36,8 +36,8 @@ interface FakeSystemd {
36
36
  * unit sees them, and appends its output to the `StandardOutput=append:`
37
37
  * file. Every call is logged.
38
38
  */
39
- export function setupFakeSystemd(atcCommand: readonly string[]): FakeSystemd {
40
- const root = mkdtempSync(join(tmpdir(), 'atc-fake-systemd-'));
39
+ export function createStubSystemd(atcCommand: readonly string[]): StubSystemd {
40
+ const root = mkdtempSync(join(tmpdir(), 'atc-stub-systemd-'));
41
41
  const binDir = join(root, 'bin');
42
42
  const procRoot = join(root, 'proc');
43
43
  const atcLine = atcCommand.map((part) => `"${part}"`).join(' ');
@@ -4,11 +4,11 @@ import { getAgentEntry } from './get-agent-entry';
4
4
 
5
5
  /**
6
6
  * The gateway a parsed config holds under an agent id: a Claude entry with a
7
- * base URL. Throws when the config holds no such agent or the entry has no
8
- * base URL.
7
+ * base URL, without the registry's `kind`, which a gateway does not carry.
8
+ * Throws when the config holds no such agent or the entry has no base URL.
9
9
  */
10
10
  export function getGatewayConfig(config: Pick<Config, 'agents'>, id: string): GatewayConfig {
11
- const entry = getAgentEntry(config, id);
11
+ const { kind: _kind, ...entry } = getAgentEntry(config, id);
12
12
 
13
13
  if (entry.baseURL === undefined) {
14
14
  throw new Error(`the agent '${id}' has no baseURL`);