@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zgeoff/atc",
3
- "version": "3.3.1",
3
+ "version": "3.4.0",
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,7 +30,7 @@
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",
33
+ "test:binary": "bash scripts/with-test-home.sh bun test --timeout 20000 e2e/daemon-*.test.ts",
34
34
  "test:gateway-binary": "bash scripts/with-test-home.sh bun test --timeout 20000 src/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",
@@ -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,
@@ -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 {
@@ -91,9 +91,14 @@ export class CodexAdapter implements AgentAdapter {
91
91
 
92
92
  private readonly authProfiles: Config['authProfiles'];
93
93
 
94
- constructor(entry: AgentEntry, config?: Pick<Config, 'authProfiles'>) {
94
+ // The Codex home whose session index holds session titles; unset, it follows
95
+ // `CODEX_HOME` or the user's home at each read.
96
+ private readonly codexHome: string | undefined;
97
+
98
+ constructor(entry: AgentEntry, config?: Pick<Config, 'authProfiles'>, codexHome?: string) {
95
99
  this.entry = entry;
96
100
  this.authProfiles = config?.authProfiles ?? new Map();
101
+ this.codexHome = codexHome;
97
102
  this.id = entry.id;
98
103
 
99
104
  if (entry.auth !== undefined) {
@@ -323,7 +328,11 @@ export class CodexAdapter implements AgentAdapter {
323
328
  return Promise.resolve(null);
324
329
  }
325
330
 
326
- const index = join(resolveAgentHome('CODEX_HOME', '.codex'), 'session_index.jsonl');
331
+ const index = join(
332
+ this.codexHome ?? resolveAgentHome('CODEX_HOME', '.codex'),
333
+ 'session_index.jsonl',
334
+ );
335
+
327
336
  let text: string;
328
337
 
329
338
  try {
@@ -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 {
@@ -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
  }
@@ -3,6 +3,7 @@ import { readFile, realpath, rename, rm, stat, writeFile } from 'node:fs/promise
3
3
  import { dirname, join } from 'node:path';
4
4
  import { isRecord } from '../shared/report';
5
5
  import { withClaudeConfigLock } from './with-claude-config-lock';
6
+ import type { ClaudeConfigLockOptions } from './with-claude-config-lock';
6
7
 
7
8
  /**
8
9
  * Accepts the Claude CLI's folder trust for one exact directory in its
@@ -15,30 +16,36 @@ import { withClaudeConfigLock } from './with-claude-config-lock';
15
16
  * Resolves to a function that takes the trust back, for a launch that
16
17
  * fails before the CLI starts: it puts back the entry as it was, unless
17
18
  * something has changed the entry since, which leaves it as it stands.
19
+ * The lock options apply to both the trust and its taking back.
18
20
  */
19
21
  export async function updateClaudeProjectTrust(
20
22
  configPath: string,
21
23
  root: string,
24
+ lockOptions: ClaudeConfigLockOptions = {},
22
25
  ): Promise<() => Promise<void>> {
23
- const previous = await withClaudeConfigLock(configPath, async () => {
24
- const config = await loadClaudeConfig(configPath);
26
+ const previous = await withClaudeConfigLock(
27
+ configPath,
28
+ async () => {
29
+ const config = await loadClaudeConfig(configPath);
25
30
 
26
- const projects = isRecord(config['projects']) ? config['projects'] : {};
27
- const entry = projects[root];
31
+ const projects = isRecord(config['projects']) ? config['projects'] : {};
32
+ const entry = projects[root];
28
33
 
29
- if (isRecord(entry) && entry['hasTrustDialogAccepted'] === true) {
30
- return null;
31
- }
34
+ if (isRecord(entry) && entry['hasTrustDialogAccepted'] === true) {
35
+ return null;
36
+ }
32
37
 
33
- const base = isRecord(entry) ? entry : {};
38
+ const base = isRecord(entry) ? entry : {};
34
39
 
35
- await writeClaudeConfig(configPath, {
36
- ...config,
37
- projects: { ...projects, [root]: { ...base, hasTrustDialogAccepted: true } },
38
- });
40
+ await writeClaudeConfig(configPath, {
41
+ ...config,
42
+ projects: { ...projects, [root]: { ...base, hasTrustDialogAccepted: true } },
43
+ });
39
44
 
40
- return { entry: isRecord(entry) ? entry : null };
41
- });
45
+ return { entry: isRecord(entry) ? entry : null };
46
+ },
47
+ lockOptions,
48
+ );
42
49
 
43
50
  if (previous === null) {
44
51
  return () => Promise.resolve();
@@ -50,22 +57,26 @@ export async function updateClaudeProjectTrust(
50
57
  });
51
58
 
52
59
  return () =>
53
- withClaudeConfigLock(configPath, async () => {
54
- const config = await loadClaudeConfig(configPath);
55
-
56
- const projects = isRecord(config['projects']) ? config['projects'] : {};
57
-
58
- if (JSON.stringify(projects[root]) !== written) {
59
- return;
60
- }
61
-
62
- const { [root]: _trusted, ...others } = projects;
63
-
64
- await writeClaudeConfig(configPath, {
65
- ...config,
66
- projects: previous.entry === null ? others : { ...others, [root]: previous.entry },
67
- });
68
- });
60
+ withClaudeConfigLock(
61
+ configPath,
62
+ async () => {
63
+ const config = await loadClaudeConfig(configPath);
64
+
65
+ const projects = isRecord(config['projects']) ? config['projects'] : {};
66
+
67
+ if (JSON.stringify(projects[root]) !== written) {
68
+ return;
69
+ }
70
+
71
+ const { [root]: _trusted, ...others } = projects;
72
+
73
+ await writeClaudeConfig(configPath, {
74
+ ...config,
75
+ projects: previous.entry === null ? others : { ...others, [root]: previous.entry },
76
+ });
77
+ },
78
+ lockOptions,
79
+ );
69
80
  }
70
81
 
71
82
  // The config as the CLI last wrote it; a missing file is an empty config,
@@ -12,6 +12,15 @@ const REFRESH_MS = 1000;
12
12
  const WAIT_MS = 5000;
13
13
  const RETRY_MS = 50;
14
14
 
15
+ /**
16
+ * Tuning a caller may pass: how often a held lock's age is refreshed, and a
17
+ * callback run each time the lock is found held by someone else.
18
+ */
19
+ export interface ClaudeConfigLockOptions {
20
+ readonly refreshMs?: number;
21
+ readonly onBusy?: () => void;
22
+ }
23
+
15
24
  /**
16
25
  * Runs the callback while holding the lock the Claude CLI takes before it
17
26
  * writes its global config: a directory beside the file named for it with
@@ -25,6 +34,7 @@ const RETRY_MS = 50;
25
34
  export async function withClaudeConfigLock<T>(
26
35
  configPath: string,
27
36
  run: () => Promise<T>,
37
+ options: ClaudeConfigLockOptions = {},
28
38
  ): Promise<T> {
29
39
  const lockPath = `${configPath}.lock`;
30
40
  const deadline = Date.now() + WAIT_MS;
@@ -34,6 +44,8 @@ export async function withClaudeConfigLock<T>(
34
44
  let held = await tryCreateLockDir(lockPath);
35
45
 
36
46
  while (held === null) {
47
+ options.onBusy?.();
48
+
37
49
  if (Date.now() > deadline) {
38
50
  throw new Error(`timed out waiting for the Claude config lock ${lockPath}`);
39
51
  }
@@ -52,7 +64,7 @@ export async function withClaudeConfigLock<T>(
52
64
 
53
65
  owned = refreshed ?? owned;
54
66
  })();
55
- }, REFRESH_MS);
67
+ }, options.refreshMs ?? REFRESH_MS);
56
68
 
57
69
  try {
58
70
  return await run();
@@ -6,14 +6,17 @@ import { buildCLIArgv } from './build-cli-argv';
6
6
 
7
7
  /**
8
8
  * Writes the atc-bridge mod that every Claude session loads with
9
- * `--plugin-dir`, with the atc command it runs rendered for this install,
10
- * and returns its folder. A file whose content already matches is left
11
- * untouched, since any write reloads the mod in every running session, and
12
- * the folder is never cleared, since Claude Code keeps its own type files
13
- * there.
9
+ * `--plugin-dir`, with the atc command it runs rendered in (by default this
10
+ * install's own), and returns its folder. A file whose content already
11
+ * matches is left untouched, since any write reloads the mod in every running
12
+ * session, and the folder is never cleared, since Claude Code keeps its own
13
+ * type files there.
14
14
  */
15
- export function writeATCBridge(dir: string = join(stateDir, 'atc-bridge')): string {
16
- const files = buildATCBridgeFiles(buildCLIArgv());
15
+ export function writeATCBridge(
16
+ dir: string = join(stateDir, 'atc-bridge'),
17
+ argv: readonly string[] = buildCLIArgv(),
18
+ ): string {
19
+ const files = buildATCBridgeFiles(argv);
17
20
 
18
21
  for (const [path, content] of Object.entries(files)) {
19
22
  const file = join(dir, path);
@@ -8,13 +8,19 @@ import type { HookSettingsProfile } from './build-hook-settings';
8
8
 
9
9
  /**
10
10
  * Writes the settings file passed to a wrangled session as
11
- * `claude --settings`, one per agent id, and returns its path.
11
+ * `claude --settings`, one per agent id, into the folder `dir`, and returns
12
+ * its path. The statusline padding comes from the Claude settings in the
13
+ * user's home `homeDir`.
12
14
  */
13
- export function writeHookSettings(profile: HookSettingsProfile): string {
14
- const file = join(stateDir, `hook-settings-${profile.id}.json`);
15
- const settings = buildHookSettings(profile, readStatuslinePadding());
15
+ export function writeHookSettings(
16
+ profile: HookSettingsProfile,
17
+ dir: string = stateDir,
18
+ homeDir: string = resolveHomeDir(),
19
+ ): string {
20
+ const file = join(dir, `hook-settings-${profile.id}.json`);
21
+ const settings = buildHookSettings(profile, readStatuslinePadding(homeDir));
16
22
 
17
- mkdirSync(stateDir, { recursive: true });
23
+ mkdirSync(dir, { recursive: true });
18
24
  writeFileSync(file, JSON.stringify(settings, null, 2));
19
25
 
20
26
  return file;
@@ -24,9 +30,9 @@ export function writeHookSettings(profile: HookSettingsProfile): string {
24
30
  * The padding on the user's own statusline, so the chained one lines up with
25
31
  * it. An unreadable or unconfigured setting is no padding.
26
32
  */
27
- function readStatuslinePadding(): number {
33
+ function readStatuslinePadding(homeDir: string): number {
28
34
  try {
29
- const raw = readFileSync(join(resolveHomeDir(), '.claude', 'settings.json'), 'utf8');
35
+ const raw = readFileSync(join(homeDir, '.claude', 'settings.json'), 'utf8');
30
36
  const user: unknown = JSON.parse(raw);
31
37
  const statusLine = isRecord(user) ? user['statusLine'] : undefined;
32
38
  const padding = isRecord(statusLine) ? statusLine['padding'] : undefined;