@zgeoff/atc 3.3.1 → 3.4.1

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 (206) hide show
  1. package/package.json +5 -3
  2. package/src/agents/agent-adapter.ts +10 -0
  3. package/src/agents/build-agent-adapters.ts +21 -3
  4. package/src/agents/build-cli-argv.ts +2 -4
  5. package/src/agents/claude-adapter.ts +13 -3
  6. package/src/agents/codex-adapter.ts +31 -2
  7. package/src/agents/gateway-adapter.ts +20 -8
  8. package/src/agents/grok-adapter.ts +16 -13
  9. package/src/agents/load-claude-config-bundle.ts +6 -6
  10. package/src/agents/print-codex-hook-file.ts +7 -3
  11. package/src/agents/print-grok-hook-file.ts +8 -3
  12. package/src/agents/render-sdk-message.ts +57 -0
  13. package/src/agents/resolve-agent-home.ts +7 -3
  14. package/src/agents/resolve-claude-global-config-path.ts +4 -4
  15. package/src/agents/start-claude-headless-run.ts +2 -57
  16. package/src/agents/update-claude-project-trust.ts +41 -30
  17. package/src/agents/with-claude-config-lock.ts +37 -12
  18. package/src/agents/write-atc-bridge.ts +10 -7
  19. package/src/agents/write-hook-settings.ts +13 -7
  20. package/src/client/boot-daemon.ts +122 -39
  21. package/src/client/dirs.ts +4 -2
  22. package/src/client/index.ts +17 -1
  23. package/src/client/log-client-event.ts +20 -0
  24. package/src/client/resolve-workspace-root.ts +10 -5
  25. package/src/client/spawn-picker.ts +200 -121
  26. package/src/client/ui.ts +7 -7
  27. package/src/clients.ts +41 -11
  28. package/src/daemon/daemon.ts +143 -33
  29. package/src/daemon/event-signal.ts +19 -3
  30. package/src/daemon/make-hook-runner.ts +82 -22
  31. package/src/daemon/permission-registry.ts +11 -6
  32. package/src/daemon/restore-fleet.ts +62 -17
  33. package/src/daemon/run-eject-handoff.ts +20 -2
  34. package/src/daemon/session-runtime.ts +0 -4
  35. package/src/daemon/sessions.ts +6 -0
  36. package/src/daemon/start-tcp-listener.ts +12 -10
  37. package/src/federation/daemon-caller.ts +25 -4
  38. package/src/federation/daemon-pool.ts +3 -1
  39. package/src/federation/open-gateway-caller.ts +6 -1
  40. package/src/gateway.ts +2 -179
  41. package/src/grants.ts +36 -11
  42. package/src/hook-report.ts +17 -3
  43. package/src/mcp/read-report-texts.ts +11 -7
  44. package/src/mcp/start-mcp-http-server.ts +5 -1
  45. package/src/mcp-http-server.ts +88 -32
  46. package/src/report.ts +21 -3
  47. package/src/run-bridge-tap.ts +36 -7
  48. package/src/run-config-migrate.ts +35 -11
  49. package/src/run-gateway-cli.ts +327 -0
  50. package/src/run-gateway.ts +51 -18
  51. package/src/shared/build-atc-command.ts +7 -3
  52. package/src/shared/bun-sqlite-driver.ts +10 -0
  53. package/src/shared/claim-daemon-lock.ts +10 -4
  54. package/src/shared/collect-dir-roots.ts +6 -6
  55. package/src/shared/collect-hooks.ts +7 -6
  56. package/src/shared/config.ts +19 -9
  57. package/src/shared/system-clock.ts +24 -0
  58. package/src/sources/github/collect-github-repos.ts +1 -1
  59. package/src/sources/github/read-git-protocol.ts +1 -1
  60. package/src/sources/github/run-gh.ts +24 -9
  61. package/src/store/state-store.ts +57 -7
  62. package/src/tap.ts +44 -13
  63. package/src/test-utils/build-mock-agent-adapter.ts +25 -0
  64. package/src/test-utils/build-mock-agent-entry.ts +32 -0
  65. package/src/test-utils/build-mock-auth-binding.ts +28 -0
  66. package/src/test-utils/build-mock-auth-profile.ts +62 -0
  67. package/src/test-utils/build-mock-daemon-record.ts +26 -0
  68. package/src/test-utils/build-mock-execution-target.ts +25 -0
  69. package/src/test-utils/build-mock-fleet-entry.ts +33 -0
  70. package/src/test-utils/build-mock-gateway-config.ts +32 -0
  71. package/src/test-utils/build-mock-imp-identity.ts +18 -0
  72. package/src/test-utils/build-mock-imp-session-request.ts +53 -0
  73. package/src/test-utils/build-mock-key-binding-claim.ts +34 -0
  74. package/src/test-utils/build-mock-message-record.ts +26 -0
  75. package/src/test-utils/build-mock-mirror-session.ts +42 -0
  76. package/src/test-utils/build-mock-registry-daemon.ts +28 -0
  77. package/src/test-utils/build-mock-session-descriptor.ts +66 -0
  78. package/src/test-utils/build-mock-session.ts +43 -0
  79. package/src/test-utils/build-mock-sortable-session-view.ts +20 -0
  80. package/src/test-utils/build-mock-stored-event.ts +20 -0
  81. package/src/test-utils/build-mock-stored-row.ts +23 -0
  82. package/src/test-utils/build-stub-attention-adapter.ts +22 -0
  83. package/src/test-utils/build-stub-bridge-context.ts +20 -0
  84. package/src/test-utils/build-stub-brokered-agent-adapter.ts +96 -0
  85. package/src/test-utils/build-stub-brokered-gateway-adapter.ts +50 -0
  86. package/src/test-utils/build-stub-channel-opener.ts +45 -0
  87. package/src/test-utils/build-stub-claude-headless-run.ts +13 -0
  88. package/src/test-utils/build-stub-client-screens.ts +33 -0
  89. package/src/test-utils/build-stub-clock.ts +67 -0
  90. package/src/test-utils/build-stub-daemon-context.ts +81 -0
  91. package/src/test-utils/build-stub-daemon-requests.ts +103 -0
  92. package/src/test-utils/{fixture-dir-provider.ts → build-stub-dir-provider.ts} +20 -12
  93. package/src/test-utils/build-stub-execution-provider.ts +112 -0
  94. package/src/test-utils/build-stub-fleet-caller.ts +60 -0
  95. package/src/test-utils/build-stub-forking-gh.ts +10 -0
  96. package/src/test-utils/build-stub-gh.ts +50 -0
  97. package/src/test-utils/build-stub-handoff-daemon.ts +11 -0
  98. package/src/test-utils/build-stub-harness-relay.ts +50 -0
  99. package/src/test-utils/build-stub-headless-runner.ts +60 -0
  100. package/src/test-utils/build-stub-held-gh.ts +19 -0
  101. package/src/test-utils/build-stub-held-inbox-source.ts +36 -0
  102. package/src/test-utils/build-stub-held-provider.ts +58 -0
  103. package/src/test-utils/build-stub-held-zoxide.ts +13 -0
  104. package/src/test-utils/build-stub-host-hold.ts +31 -0
  105. package/src/test-utils/build-stub-host-provider.ts +26 -0
  106. package/src/test-utils/build-stub-log.ts +15 -0
  107. package/src/test-utils/build-stub-mcp-claude.ts +26 -0
  108. package/src/test-utils/build-stub-mcp-grok.ts +19 -0
  109. package/src/test-utils/build-stub-mcp-stdio-server.ts +20 -0
  110. package/src/test-utils/build-stub-peer-socket.ts +81 -0
  111. package/src/test-utils/build-stub-picker-host.ts +36 -0
  112. package/src/test-utils/build-stub-proxied-agent-adapter.ts +29 -0
  113. package/src/test-utils/build-stub-pty-provider.ts +53 -0
  114. package/src/test-utils/build-stub-recording-atc.ts +10 -0
  115. package/src/test-utils/build-stub-recording-filter.ts +9 -0
  116. package/src/test-utils/build-stub-recording-git.ts +8 -0
  117. package/src/test-utils/build-stub-signed-in-gh.ts +18 -0
  118. package/src/test-utils/build-stub-signed-out-gh.ts +8 -0
  119. package/src/test-utils/build-stub-soft-kill-provider.ts +29 -0
  120. package/src/test-utils/build-stub-targets.ts +58 -0
  121. package/src/test-utils/build-stub-terminal.ts +19 -0
  122. package/src/test-utils/build-stub-timeout-scheduler.ts +48 -0
  123. package/src/test-utils/build-stub-tui-claude.ts +47 -0
  124. package/src/test-utils/build-stub-tui-grok.ts +53 -0
  125. package/src/test-utils/build-stub-wait-clock.ts +23 -0
  126. package/src/test-utils/build-target-options-from-config.ts +37 -0
  127. package/src/test-utils/collect-process-tree.ts +75 -0
  128. package/src/test-utils/create-git-fixture.ts +117 -0
  129. package/src/test-utils/create-migrated-state-db.ts +12 -10
  130. package/src/test-utils/create-stub-bin.ts +19 -0
  131. package/src/test-utils/create-stub-claude.ts +75 -0
  132. package/src/test-utils/create-stub-codex.ts +31 -0
  133. package/src/test-utils/create-stub-composer.ts +65 -0
  134. package/src/test-utils/create-stub-echo-claude.ts +21 -0
  135. package/src/test-utils/create-stub-failing-agent-adapter.ts +111 -0
  136. package/src/test-utils/create-stub-grok.ts +44 -0
  137. package/src/test-utils/create-stub-guest-clis.ts +55 -0
  138. package/src/test-utils/create-stub-harness-guest.ts +35 -0
  139. package/src/test-utils/{fixture-imp-port.ts → create-stub-imp-port.ts} +144 -63
  140. package/src/test-utils/create-stub-recording-claude.ts +20 -0
  141. package/src/test-utils/create-stub-recording-codex.ts +20 -0
  142. package/src/test-utils/create-stub-shared-server-codex.ts +56 -0
  143. package/src/test-utils/{setup-fake-systemd.ts → create-stub-systemd.ts} +15 -9
  144. package/src/test-utils/get-gateway-config.ts +3 -3
  145. package/src/test-utils/get-only-effect-ref.ts +21 -0
  146. package/src/test-utils/get-only-imp-name.ts +19 -0
  147. package/src/test-utils/get-records.ts +20 -0
  148. package/src/test-utils/get-string.ts +14 -0
  149. package/src/test-utils/keys.ts +26 -0
  150. package/src/test-utils/merge-deep.ts +36 -0
  151. package/src/test-utils/mock-overrides.ts +13 -0
  152. package/src/test-utils/open-repo-step.ts +24 -0
  153. package/src/test-utils/read-query-plan.ts +22 -0
  154. package/src/test-utils/register-test-cleanup.ts +63 -0
  155. package/src/test-utils/resolve-atc-command.ts +13 -0
  156. package/src/test-utils/resolve-gateway-command.ts +13 -0
  157. package/src/test-utils/run-atc.ts +56 -0
  158. package/src/test-utils/run-legacy-daemon.ts +27 -5
  159. package/src/test-utils/run-listener-daemon.ts +5 -10
  160. package/src/test-utils/run-mcp-authorization.ts +7 -4
  161. package/src/test-utils/run-source-daemon.ts +2 -2
  162. package/src/test-utils/seed-faker.ts +7 -0
  163. package/src/test-utils/send-lines-before-handshake.ts +35 -0
  164. package/src/test-utils/send-mcp-request.ts +25 -0
  165. package/src/test-utils/setup-mcp-home.ts +110 -0
  166. package/src/test-utils/setup-mcp-http.ts +26 -30
  167. package/src/test-utils/setup-temp-dir.ts +7 -6
  168. package/src/test-utils/spawn-claude-session.ts +34 -0
  169. package/src/test-utils/spawn-grok-session.ts +39 -0
  170. package/src/test-utils/start-cut-proxy.ts +29 -8
  171. package/src/test-utils/start-daemon-process.ts +193 -0
  172. package/src/test-utils/start-git-http-server.ts +28 -4
  173. package/src/test-utils/start-mcp-stdio.ts +190 -0
  174. package/src/test-utils/start-output-capture.ts +20 -0
  175. package/src/test-utils/start-stub-answering-listener.ts +65 -0
  176. package/src/test-utils/start-stub-closing-listener.ts +31 -0
  177. package/src/test-utils/start-stub-dropping-daemon.ts +105 -0
  178. package/src/test-utils/start-stub-impd-info.ts +38 -0
  179. package/src/test-utils/start-stub-impd.ts +143 -0
  180. package/src/test-utils/start-stub-legacy-daemon.ts +171 -0
  181. package/src/test-utils/start-stub-mcp-server.ts +55 -0
  182. package/src/test-utils/start-stub-recording-listener.ts +47 -0
  183. package/src/test-utils/start-stub-refusing-listener.ts +43 -0
  184. package/src/test-utils/start-stub-reporter-socket.ts +79 -0
  185. package/src/test-utils/start-stub-restore-daemon.ts +59 -0
  186. package/src/test-utils/start-stub-session-bridge.ts +100 -0
  187. package/src/test-utils/start-stub-silent-server.ts +38 -0
  188. package/src/test-utils/start-stub-source-daemon.ts +41 -0
  189. package/src/test-utils/start-stub-stalled-client.ts +61 -0
  190. package/src/test-utils/start-stub-stalled-listener.ts +44 -0
  191. package/src/test-utils/start-stub-tap.ts +38 -0
  192. package/src/test-utils/start-stub-tcp-daemon.ts +75 -0
  193. package/src/test-utils/start-stub-telemetry-collector.ts +32 -0
  194. package/src/test-utils/start-stub-unanswering-listener.ts +41 -0
  195. package/src/test-utils/start-stub-unanswering-unix-listener.ts +38 -0
  196. package/src/test-utils/start-test-daemon.ts +229 -0
  197. package/src/test-utils/start-tui-harness.ts +291 -0
  198. package/src/test-utils/subscribe-to-socket-lines.ts +81 -14
  199. package/src/test-utils/try-send-request.ts +29 -0
  200. package/src/test-utils/wait-for-event.ts +41 -0
  201. package/src/test-utils/wait-for.ts +15 -3
  202. package/src/verify-restored-fleet.ts +30 -17
  203. package/src/workspace/check-repository-access.ts +4 -0
  204. package/src/workspace/collect-remote-refs.ts +4 -2
  205. package/src/workspace/run-git.ts +6 -0
  206. package/src/test-utils/start-legacy-daemon.ts +0 -111
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zgeoff/atc",
3
- "version": "3.3.1",
3
+ "version": "3.4.1",
4
4
  "description": "Terminal control tower for coding-agent sessions",
5
5
  "homepage": "https://github.com/zgeoff/atc#readme",
6
6
  "bugs": "https://github.com/zgeoff/atc/issues",
@@ -30,8 +30,8 @@
30
30
  "lint:fix": "oxlint --type-aware --type-check --report-unused-disable-directives-severity error --fix",
31
31
  "test": "bash scripts/with-test-home.sh bun test --parallel --timings=bun-test-timings.json",
32
32
  "test:timings": "bash scripts/with-test-home.sh bun test --parallel --timings=bun-test-timings.json --update-timings",
33
- "test:binary": "bash scripts/with-test-home.sh bun test --timeout 20000 e2e/daemon-e2e.test.ts",
34
- "test:gateway-binary": "bash scripts/with-test-home.sh bun test --timeout 20000 src/gateway.test.ts",
33
+ "test:binary": "bash scripts/with-test-home.sh bun test --timeout 20000 e2e/daemon-*.test.ts",
34
+ "test:gateway-binary": "bash scripts/with-test-home.sh bun test --timeout 20000 e2e/gateway-*.test.ts",
35
35
  "test:atc-bridge": "bash scripts/with-test-home.sh bash scripts/test-atc-bridge.sh",
36
36
  "test:isolation": "bash scripts/check-test-isolation.sh",
37
37
  "typecheck": "tsc -p tsconfig.json --noEmit",
@@ -57,6 +57,7 @@
57
57
  },
58
58
  "devDependencies": {
59
59
  "@commitlint/cli": "21.2.0",
60
+ "@faker-js/faker": "10.6.0",
60
61
  "@stylistic/eslint-plugin": "5.10.0",
61
62
  "@tsconfig/strictest": "2.0.8",
62
63
  "@types/bun": "1.4.2",
@@ -68,6 +69,7 @@
68
69
  "oxfmt": "0.57.0",
69
70
  "oxlint": "1.73.0",
70
71
  "oxlint-tsgolint": "0.24.0",
72
+ "tiny-invariant": "1.3.3",
71
73
  "tslib": "2.8.1",
72
74
  "type-fest": "5.8.0",
73
75
  "typescript": "7.0.2"
@@ -179,6 +179,16 @@ export interface HeadlessRunEvents {
179
179
  readonly onNeedsYou: (msg: string) => void;
180
180
  }
181
181
 
182
+ /**
183
+ * Where a Claude-family adapter writes its generated settings files and
184
+ * where it finds the user's home, whose Claude config it reads. A field
185
+ * left out takes atc's state folder or the running user's home.
186
+ */
187
+ export interface ClaudeHostPaths {
188
+ readonly stateDir?: string;
189
+ readonly homeDir?: string;
190
+ }
191
+
182
192
  export type HeadlessRunner = (
183
193
  opts: HeadlessRunRequest,
184
194
  hooks: HeadlessRunEvents,
@@ -1,11 +1,20 @@
1
1
  import type { Config } from '../shared/config';
2
- import type { AgentAdapter } from './agent-adapter';
2
+ import type { AgentAdapter, ClaudeHostPaths } from './agent-adapter';
3
3
  import { ClaudeAdapter } from './claude-adapter';
4
4
  import { CodexAdapter } from './codex-adapter';
5
5
  import { GatewayAdapter } from './gateway-adapter';
6
6
  import { GrokAdapter } from './grok-adapter';
7
7
  import type { ClaudeHeadlessRun } from './make-claude-headless-runner';
8
8
 
9
+ /**
10
+ * Where the Claude and gateway adapters write their settings file and their
11
+ * copy of the atc-bridge mod, and the home whose Claude config they read. A
12
+ * field left out takes atc's state folder or the running user's home.
13
+ */
14
+ interface AgentAdapterPaths extends ClaudeHostPaths {
15
+ readonly bridgeTarget?: string;
16
+ }
17
+
9
18
  /**
10
19
  * One adapter per registry entry, in registry order. A Claude entry with a
11
20
  * base URL is a gateway, and one without is stock Claude.
@@ -13,7 +22,10 @@ import type { ClaudeHeadlessRun } from './make-claude-headless-runner';
13
22
  export function buildAgentAdapters(
14
23
  config: Config,
15
24
  headlessRun: ClaudeHeadlessRun | null = null,
25
+ paths: AgentAdapterPaths = {},
16
26
  ): AgentAdapter[] {
27
+ const { bridgeTarget, ...hostPaths } = paths;
28
+
17
29
  return config.agents.map((entry): AgentAdapter => {
18
30
  if (entry.kind === 'codex') {
19
31
  return new CodexAdapter(entry, config);
@@ -24,9 +36,15 @@ export function buildAgentAdapters(
24
36
  }
25
37
 
26
38
  if (entry.baseURL === undefined) {
27
- return new ClaudeAdapter(entry, config, headlessRun);
39
+ return new ClaudeAdapter(entry, config, headlessRun, bridgeTarget, hostPaths);
28
40
  }
29
41
 
30
- return new GatewayAdapter({ ...entry, baseURL: entry.baseURL }, config, headlessRun);
42
+ return new GatewayAdapter(
43
+ { ...entry, baseURL: entry.baseURL },
44
+ config,
45
+ headlessRun,
46
+ bridgeTarget,
47
+ hostPaths,
48
+ );
31
49
  });
32
50
  }
@@ -6,8 +6,6 @@ import { isCompiledBinary } from '../shared/is-compiled-binary';
6
6
  * the CLI entry path follows the runtime; a compiled binary is itself the
7
7
  * entry.
8
8
  */
9
- export function buildCLIArgv(): string[] {
10
- return isCompiledBinary()
11
- ? [process.execPath]
12
- : [process.execPath, join(import.meta.dir, '..', 'cli.ts')];
9
+ export function buildCLIArgv(compiled: boolean = isCompiledBinary()): string[] {
10
+ return compiled ? [process.execPath] : [process.execPath, join(import.meta.dir, '..', 'cli.ts')];
13
11
  }
@@ -19,6 +19,7 @@ import type {
19
19
  AgentAdapter,
20
20
  AgentProfile,
21
21
  AuthSelection,
22
+ ClaudeHostPaths,
22
23
  GuestPaths,
23
24
  GuestSpawnPlan,
24
25
  HeadlessRunner,
@@ -106,13 +107,17 @@ export class ClaudeAdapter implements AgentAdapter {
106
107
 
107
108
  private readonly bridgeTarget: string | undefined;
108
109
 
110
+ private readonly hostPaths: ClaudeHostPaths;
111
+
109
112
  constructor(
110
113
  entry: AgentEntry,
111
114
  config: Pick<Config, 'authProfiles'>,
112
115
  headlessRun: ClaudeHeadlessRun | null = null,
113
116
  bridgeTarget?: string,
117
+ hostPaths: ClaudeHostPaths = {},
114
118
  ) {
115
119
  this.bridgeTarget = bridgeTarget;
120
+ this.hostPaths = hostPaths;
116
121
  this.entry = entry;
117
122
  this.authProfiles = config.authProfiles;
118
123
  this.id = entry.id;
@@ -282,8 +287,9 @@ export class ClaudeAdapter implements AgentAdapter {
282
287
  );
283
288
 
284
289
  const bundle = loadClaudeConfigBundle(
285
- resolveAgentHome('CLAUDE_CONFIG_DIR', '.claude'),
290
+ resolveAgentHome('CLAUDE_CONFIG_DIR', '.claude', this.hostPaths.homeDir),
286
291
  launch.env.CLAUDE_CONFIG_DIR,
292
+ this.hostPaths.homeDir,
287
293
  );
288
294
 
289
295
  const userSettings = bundle['settings.json'];
@@ -406,7 +412,11 @@ export class ClaudeAdapter implements AgentAdapter {
406
412
  }
407
413
 
408
414
  private writeSettings(): string {
409
- this.settingsFile ??= writeHookSettings(this.buildSettingsProfile({}));
415
+ this.settingsFile ??= writeHookSettings(
416
+ this.buildSettingsProfile({}),
417
+ this.hostPaths.stateDir,
418
+ this.hostPaths.homeDir,
419
+ );
410
420
 
411
421
  return this.settingsFile;
412
422
  }
@@ -420,7 +430,7 @@ export class ClaudeAdapter implements AgentAdapter {
420
430
  // A session on the daemon's machine reads the user's own Claude config,
421
431
  // so trust for the clone is that config's entry for the clone alone.
422
432
  updateLocalWorkspaceTrust(root: string): Promise<() => Promise<void>> {
423
- return updateClaudeProjectTrust(resolveClaudeGlobalConfigPath(), root);
433
+ return updateClaudeProjectTrust(resolveClaudeGlobalConfigPath(this.hostPaths.homeDir), root);
424
434
  }
425
435
 
426
436
  normalizeHook(e: HookEvent): AdapterEvent {
@@ -53,6 +53,11 @@ type CodexHookPayload = z.infer<typeof CODEX_HOOK_PAYLOAD_SCHEMA>;
53
53
  // The spellings of the Codex CLI's model flag.
54
54
  const CODEX_MODEL_FLAGS = ['-m', '--model'];
55
55
 
56
+ // The Codex CLI flag that runs a terminal without the shared background
57
+ // server, and the flag that connects it to a remote server instead.
58
+ const NO_DAEMON_FLAG = '--no-daemon';
59
+ const REMOTE_FLAG = '--remote';
60
+
56
61
  /**
57
62
  * The Codex CLI adapter: spawn arguments, hook payload mapping, resume
58
63
  * semantics, and session_index.jsonl name-pulling. Hooks are a user-installed
@@ -91,9 +96,14 @@ export class CodexAdapter implements AgentAdapter {
91
96
 
92
97
  private readonly authProfiles: Config['authProfiles'];
93
98
 
94
- constructor(entry: AgentEntry, config?: Pick<Config, 'authProfiles'>) {
99
+ // The Codex home whose session index holds session titles; unset, it follows
100
+ // `CODEX_HOME` or the user's home at each read.
101
+ private readonly codexHome: string | undefined;
102
+
103
+ constructor(entry: AgentEntry, config?: Pick<Config, 'authProfiles'>, codexHome?: string) {
95
104
  this.entry = entry;
96
105
  this.authProfiles = config?.authProfiles ?? new Map();
106
+ this.codexHome = codexHome;
97
107
  this.id = entry.id;
98
108
 
99
109
  if (entry.auth !== undefined) {
@@ -113,6 +123,14 @@ export class CodexAdapter implements AgentAdapter {
113
123
  return {
114
124
  bin: this.entry.bin,
115
125
  args: [
126
+ // Codex's shared background server keeps the environment of the
127
+ // terminal that started it and runs every thread's hooks and tools
128
+ // there, so a terminal on it reports as that first session. Each
129
+ // terminal therefore runs its own server. The configured arguments
130
+ // already carry the flag, or connect to a remote server, which Codex
131
+ // refuses to combine with it; either way Codex takes no second copy.
132
+ ...(this.entry.args.some(isDaemonChoiceFlag) ? [] : [NO_DAEMON_FLAG]),
133
+
116
134
  // A model override replaces any model flag the configured arguments
117
135
  // carry, and travels as its own argument.
118
136
  ...(opts.model === undefined
@@ -323,7 +341,11 @@ export class CodexAdapter implements AgentAdapter {
323
341
  return Promise.resolve(null);
324
342
  }
325
343
 
326
- const index = join(resolveAgentHome('CODEX_HOME', '.codex'), 'session_index.jsonl');
344
+ const index = join(
345
+ this.codexHome ?? resolveAgentHome('CODEX_HOME', '.codex'),
346
+ 'session_index.jsonl',
347
+ );
348
+
327
349
  let text: string;
328
350
 
329
351
  try {
@@ -411,3 +433,10 @@ function buildCodexSpawnOptions(codexArgs: readonly string[]): SpawnOptionSpecs
411
433
  },
412
434
  };
413
435
  }
436
+
437
+ // Whether a configured argument already decides which server a terminal
438
+ // uses: the flag that runs it without the shared one, or the remote server
439
+ // flag in either of its spellings.
440
+ function isDaemonChoiceFlag(arg: string): boolean {
441
+ return arg === NO_DAEMON_FLAG || arg === REMOTE_FLAG || arg.startsWith(`${REMOTE_FLAG}=`);
442
+ }
@@ -13,6 +13,7 @@ import type {
13
13
  AgentAdapter,
14
14
  AgentProfile,
15
15
  AuthSelection,
16
+ ClaudeHostPaths,
16
17
  GuestPaths,
17
18
  GuestSpawnPlan,
18
19
  HeadlessRunner,
@@ -87,13 +88,17 @@ export class GatewayAdapter implements AgentAdapter {
87
88
 
88
89
  private readonly bridgeTarget: string | undefined;
89
90
 
91
+ private readonly hostPaths: ClaudeHostPaths;
92
+
90
93
  constructor(
91
94
  gateway: GatewayConfig,
92
95
  config: Config,
93
96
  headlessRun: ClaudeHeadlessRun | null = null,
94
97
  bridgeTarget?: string,
98
+ hostPaths: ClaudeHostPaths = {},
95
99
  ) {
96
100
  this.bridgeTarget = bridgeTarget;
101
+ this.hostPaths = hostPaths;
97
102
  this.gateway = gateway;
98
103
  this.config = config;
99
104
  this.id = gateway.id;
@@ -121,6 +126,9 @@ export class GatewayAdapter implements AgentAdapter {
121
126
  env: {},
122
127
  },
123
128
  config,
129
+ null,
130
+ undefined,
131
+ hostPaths,
124
132
  );
125
133
 
126
134
  this.headlessRunner =
@@ -433,14 +441,18 @@ export class GatewayAdapter implements AgentAdapter {
433
441
  }
434
442
 
435
443
  private writeSettings(): string {
436
- this.settingsFile ??= writeHookSettings({
437
- id: this.id,
438
- env: { ANTHROPIC_BASE_URL: this.gateway.baseURL, ...this.gateway.env },
439
- ...(this.gateway.apiKeyHelper === undefined
440
- ? {}
441
- : { apiKeyHelper: this.gateway.apiKeyHelper }),
442
- ...(this.gateway.settings === undefined ? {} : { settings: this.gateway.settings }),
443
- });
444
+ this.settingsFile ??= writeHookSettings(
445
+ {
446
+ id: this.id,
447
+ env: { ANTHROPIC_BASE_URL: this.gateway.baseURL, ...this.gateway.env },
448
+ ...(this.gateway.apiKeyHelper === undefined
449
+ ? {}
450
+ : { apiKeyHelper: this.gateway.apiKeyHelper }),
451
+ ...(this.gateway.settings === undefined ? {} : { settings: this.gateway.settings }),
452
+ },
453
+ this.hostPaths.stateDir,
454
+ this.hostPaths.homeDir,
455
+ );
444
456
 
445
457
  return this.settingsFile;
446
458
  }
@@ -90,8 +90,13 @@ export class GrokAdapter implements AgentAdapter {
90
90
 
91
91
  private readonly hookState = new Map<SessionID, GrokSessionHookState>();
92
92
 
93
- constructor(entry: AgentEntry) {
93
+ // Grok's home directory, or null to read it from GROK_HOME (or ~/.grok)
94
+ // each time it is needed.
95
+ private readonly home: string | null;
96
+
97
+ constructor(entry: AgentEntry, home: string | null = null) {
94
98
  this.entry = entry;
99
+ this.home = home;
95
100
  this.id = entry.id;
96
101
 
97
102
  this.profile = {
@@ -130,7 +135,7 @@ export class GrokAdapter implements AgentAdapter {
130
135
  const named: AdapterEvent = {
131
136
  ...base,
132
137
  ...(agentSessionID !== undefined && payload.cwd !== undefined
133
- ? { nameSource: buildGrokNameSource(agentSessionID, payload.cwd) }
138
+ ? { nameSource: buildGrokNameSource(this.resolveHome(), agentSessionID, payload.cwd) }
134
139
  : {}),
135
140
  };
136
141
 
@@ -213,7 +218,7 @@ export class GrokAdapter implements AgentAdapter {
213
218
  }
214
219
 
215
220
  loadName(source: string, namedBy: 'user' | 'auto' | 'agent'): Promise<NameUpdate | null> {
216
- const parsed = readSummary(source) ?? readSummary(findGrokSummary(source));
221
+ const parsed = readSummary(source) ?? readSummary(findGrokSummary(this.resolveHome(), source));
217
222
 
218
223
  if (parsed === null) {
219
224
  return Promise.resolve(null);
@@ -277,6 +282,10 @@ export class GrokAdapter implements AgentAdapter {
277
282
  return event;
278
283
  }
279
284
 
285
+ private resolveHome(): string {
286
+ return this.home ?? resolveAgentHome('GROK_HOME', '.grok');
287
+ }
288
+
280
289
  // A Map preserves insertion order, so the first key is the oldest entry.
281
290
  private removeOldestHookState(): void {
282
291
  const oldest = this.hookState.keys().next().value;
@@ -287,14 +296,8 @@ export class GrokAdapter implements AgentAdapter {
287
296
  }
288
297
  }
289
298
 
290
- function buildGrokNameSource(sessionID: AgentSessionID, cwd: string): string {
291
- return join(
292
- resolveAgentHome('GROK_HOME', '.grok'),
293
- 'sessions',
294
- encodeURIComponent(cwd),
295
- sessionID,
296
- 'summary.json',
297
- );
299
+ function buildGrokNameSource(home: string, sessionID: AgentSessionID, cwd: string): string {
300
+ return join(home, 'sessions', encodeURIComponent(cwd), sessionID, 'summary.json');
298
301
  }
299
302
 
300
303
  function buildTurnDoneEvent(named: Readonly<AdapterEvent>, payload: GrokHookPayload): AdapterEvent {
@@ -323,14 +326,14 @@ function readSummary(source: string | null): Record<string, unknown> | null {
323
326
  }
324
327
  }
325
328
 
326
- function findGrokSummary(source: string): string | null {
329
+ function findGrokSummary(home: string, source: string): string | null {
327
330
  const sessionID = source.split('/').at(-2);
328
331
 
329
332
  if (sessionID === undefined || sessionID === '') {
330
333
  return null;
331
334
  }
332
335
 
333
- const root = join(resolveAgentHome('GROK_HOME', '.grok'), 'sessions');
336
+ const root = join(home, 'sessions');
334
337
 
335
338
  try {
336
339
  for (const group of readdirSync(root)) {
@@ -17,17 +17,19 @@ import { buildClaudeBundleSettings } from './build-claude-bundle-settings';
17
17
  * starts with a dot. A symlink ships only when what it resolves to could
18
18
  * ship by its own path: a file whose name starts with a dot never does, and
19
19
  * neither does a file in the host's folder outside the shipped entries. A
20
- * missing entry ships nothing.
20
+ * missing entry ships nothing. `homeDir` is the user's home, which decides
21
+ * whether the host folder is the default one.
21
22
  */
22
23
  export function loadClaudeConfigBundle(
23
24
  hostDir: string,
24
25
  guestDir: string,
26
+ homeDir: string = resolveHomeDir(),
25
27
  ): Record<string, GuestFile> {
26
28
  const hostSettings = readSettings(join(hostDir, 'settings.json'));
27
29
 
28
30
  const settings = buildClaudeBundleSettings(
29
31
  hostSettings,
30
- buildHostDirSpellings(hostDir),
32
+ buildHostDirSpellings(hostDir, homeDir),
31
33
  guestDir,
32
34
  );
33
35
 
@@ -66,10 +68,8 @@ export function loadClaudeConfigBundle(
66
68
  // The ways a command can spell the host's config folder: its path, and
67
69
  // when it is the default folder in the user's home, the home-relative forms
68
70
  // a shell expands.
69
- function buildHostDirSpellings(hostDir: string): string[] {
70
- return hostDir === join(resolveHomeDir(), '.claude')
71
- ? [hostDir, '~/.claude', '$HOME/.claude']
72
- : [hostDir];
71
+ function buildHostDirSpellings(hostDir: string, homeDir: string): string[] {
72
+ return hostDir === join(homeDir, '.claude') ? [hostDir, '~/.claude', '$HOME/.claude'] : [hostDir];
73
73
  }
74
74
 
75
75
  // The host's settings, or none when the file is missing or is not JSON,
@@ -4,8 +4,12 @@ import { buildCodexHookFile } from './build-codex-hook-file';
4
4
  * Print the Codex hook entries to stdout. The operator merges them into
5
5
  * `$CODEX_HOME/hooks.json` and trusts them once in the Codex TUI — Codex
6
6
  * parses untrusted hooks but never runs them. atc never writes the user's
7
- * own Codex config.
7
+ * own Codex config. `write` takes the printed text, stdout by default.
8
8
  */
9
- export function printCodexHookFile(): void {
10
- process.stdout.write(buildCodexHookFile());
9
+ export function printCodexHookFile(
10
+ write: (text: string) => void = (text) => {
11
+ process.stdout.write(text);
12
+ },
13
+ ): void {
14
+ write(buildCodexHookFile());
11
15
  }
@@ -12,10 +12,15 @@ const GROK_HOOK_EVENTS = [
12
12
 
13
13
  /**
14
14
  * Print the Grok hook file to stdout. The operator copies it to
15
- * `$GROK_HOME/hooks/atc-reporter.json`; atc never writes that path.
15
+ * `$GROK_HOME/hooks/atc-reporter.json`; atc never writes that path. `write`
16
+ * takes the printed text, stdout by default.
16
17
  */
17
- export function printGrokHookFile(): void {
18
- process.stdout.write(buildGrokHookFile());
18
+ export function printGrokHookFile(
19
+ write: (text: string) => void = (text) => {
20
+ process.stdout.write(text);
21
+ },
22
+ ): void {
23
+ write(buildGrokHookFile());
19
24
  }
20
25
 
21
26
  function buildGrokHookFile(): string {
@@ -0,0 +1,57 @@
1
+ import { isRecord } from '../shared/report';
2
+ import { truncateSummary } from '../shared/truncate-summary';
3
+
4
+ interface RenderableMessage {
5
+ readonly type: string;
6
+ readonly subtype?: string;
7
+ readonly result?: string;
8
+ readonly message?: unknown;
9
+ }
10
+
11
+ /**
12
+ * One structured SDK message → zero or one plain-text line for the session's
13
+ * output pipe: assistant text verbatim, tool calls as compact one-liners,
14
+ * results as a closing line.
15
+ */
16
+ export function renderSDKMessage(message: RenderableMessage): string | null {
17
+ if (message.type === 'assistant') {
18
+ const inner = message.message;
19
+
20
+ if (!isRecord(inner) || !Array.isArray(inner['content'])) {
21
+ return null;
22
+ }
23
+
24
+ const content = inner['content'];
25
+ const lines: string[] = [];
26
+
27
+ for (const raw of content) {
28
+ const block: unknown = raw;
29
+
30
+ if (!isRecord(block)) {
31
+ continue;
32
+ }
33
+
34
+ if (
35
+ block['type'] === 'text' &&
36
+ typeof block['text'] === 'string' &&
37
+ block['text'].trim() !== ''
38
+ ) {
39
+ lines.push(block['text'].replaceAll('\n', '\r\n'));
40
+ }
41
+
42
+ if (block['type'] === 'tool_use' && typeof block['name'] === 'string') {
43
+ lines.push(`⚙ ${block['name']} ${truncateSummary(JSON.stringify(block['input'] ?? {}))}`);
44
+ }
45
+ }
46
+
47
+ return lines.length === 0 ? null : lines.join('\r\n');
48
+ }
49
+
50
+ if (message.type === 'result') {
51
+ return message.subtype === 'success'
52
+ ? `— headless turn done: ${truncateSummary(message.result ?? '')}`
53
+ : `— headless turn stopped: ${message.subtype ?? 'unknown'}`;
54
+ }
55
+
56
+ return null;
57
+ }
@@ -4,10 +4,14 @@ import { resolveHomeDir } from '../shared/resolve-home-dir';
4
4
  /**
5
5
  * An agent CLI's home directory: the named environment variable when it is
6
6
  * set and non-empty, otherwise the given default directory under the user's
7
- * home.
7
+ * home `homeDir`.
8
8
  */
9
- export function resolveAgentHome(envVar: string, defaultDirName: string): string {
9
+ export function resolveAgentHome(
10
+ envVar: string,
11
+ defaultDirName: string,
12
+ homeDir: string = resolveHomeDir(),
13
+ ): string {
10
14
  const home = process.env[envVar];
11
15
 
12
- return home !== undefined && home !== '' ? home : join(resolveHomeDir(), defaultDirName);
16
+ return home !== undefined && home !== '' ? home : join(homeDir, defaultDirName);
13
17
  }
@@ -7,12 +7,12 @@ import { resolveHomeDir } from '../shared/resolve-home-dir';
7
7
  * in, folder trust included, resolved as the CLI resolves it: a legacy
8
8
  * `.config.json` in its config folder when one exists, otherwise
9
9
  * `.claude.json` in `$CLAUDE_CONFIG_DIR`, or in the user's home when that
10
- * is unset or empty.
10
+ * is unset or empty, where `homeDir` is the user's home.
11
11
  */
12
- export function resolveClaudeGlobalConfigPath(): string {
12
+ export function resolveClaudeGlobalConfigPath(homeDir: string = resolveHomeDir()): string {
13
13
  const configDir = process.env['CLAUDE_CONFIG_DIR'];
14
14
  const custom = configDir !== undefined && configDir !== '' ? configDir : null;
15
- const legacy = join(custom ?? join(resolveHomeDir(), '.claude'), '.config.json');
15
+ const legacy = join(custom ?? join(homeDir, '.claude'), '.config.json');
16
16
 
17
- return existsSync(legacy) ? legacy : join(custom ?? resolveHomeDir(), '.claude.json');
17
+ return existsSync(legacy) ? legacy : join(custom ?? homeDir, '.claude.json');
18
18
  }
@@ -1,8 +1,8 @@
1
1
  import { isCompiledBinary } from '../shared/is-compiled-binary';
2
- import { isRecord } from '../shared/report';
3
2
  import { truncateSummary } from '../shared/truncate-summary';
4
3
  import { buildClaudeQueryOptions } from './build-claude-query-options';
5
4
  import type { ClaudeHeadlessRunRequest } from './build-claude-query-options';
5
+ import { renderSDKMessage } from './render-sdk-message';
6
6
 
7
7
  interface HeadlessRunHooks {
8
8
  readonly onOutput: (text: string) => void;
@@ -47,7 +47,7 @@ export function startClaudeHeadlessRun(
47
47
  });
48
48
 
49
49
  for await (const message of stream) {
50
- const rendered = renderSdkMessage(message);
50
+ const rendered = renderSDKMessage(message);
51
51
 
52
52
  if (rendered !== null) {
53
53
  hooks.onOutput(`${rendered}\r\n`);
@@ -84,58 +84,3 @@ export function startClaudeHeadlessRun(
84
84
  },
85
85
  };
86
86
  }
87
-
88
- interface RenderableMessage {
89
- readonly type: string;
90
- readonly subtype?: string;
91
- readonly result?: string;
92
- readonly message?: unknown;
93
- }
94
-
95
- /**
96
- * One structured SDK message → zero or one plain-text line for the session's
97
- * output pipe: assistant text verbatim, tool calls as compact one-liners,
98
- * results as a closing line.
99
- */
100
- export function renderSdkMessage(message: RenderableMessage): string | null {
101
- if (message.type === 'assistant') {
102
- const inner = message.message;
103
-
104
- if (!isRecord(inner) || !Array.isArray(inner['content'])) {
105
- return null;
106
- }
107
-
108
- const content = inner['content'];
109
- const lines: string[] = [];
110
-
111
- for (const raw of content) {
112
- const block: unknown = raw;
113
-
114
- if (!isRecord(block)) {
115
- continue;
116
- }
117
-
118
- if (
119
- block['type'] === 'text' &&
120
- typeof block['text'] === 'string' &&
121
- block['text'].trim() !== ''
122
- ) {
123
- lines.push(block['text'].replaceAll('\n', '\r\n'));
124
- }
125
-
126
- if (block['type'] === 'tool_use' && typeof block['name'] === 'string') {
127
- lines.push(`⚙ ${block['name']} ${truncateSummary(JSON.stringify(block['input'] ?? {}))}`);
128
- }
129
- }
130
-
131
- return lines.length === 0 ? null : lines.join('\r\n');
132
- }
133
-
134
- if (message.type === 'result') {
135
- return message.subtype === 'success'
136
- ? `— headless turn done: ${truncateSummary(message.result ?? '')}`
137
- : `— headless turn stopped: ${message.subtype ?? 'unknown'}`;
138
- }
139
-
140
- return null;
141
- }