@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
@@ -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,
@@ -1,5 +1,7 @@
1
1
  import { mkdir, rmdir, stat, utimes } from 'node:fs/promises';
2
2
  import { dirname } from 'node:path';
3
+ import type { Clock } from '../shared/system-clock';
4
+ import { systemClock } from '../shared/system-clock';
3
5
 
4
6
  // A lock older than this belongs to a holder that died: the Claude CLI's
5
7
  // lock library refreshes a held lock's age well inside it.
@@ -12,6 +14,16 @@ const REFRESH_MS = 1000;
12
14
  const WAIT_MS = 5000;
13
15
  const RETRY_MS = 50;
14
16
 
17
+ /**
18
+ * Tuning a caller may pass: the clock that times and stamps each refresh of
19
+ * a held lock's age, and a callback run each time the lock is found held by
20
+ * someone else.
21
+ */
22
+ export interface ClaudeConfigLockOptions {
23
+ readonly clock?: Clock;
24
+ readonly onBusy?: () => void;
25
+ }
26
+
15
27
  /**
16
28
  * Runs the callback while holding the lock the Claude CLI takes before it
17
29
  * writes its global config: a directory beside the file named for it with
@@ -25,6 +37,7 @@ const RETRY_MS = 50;
25
37
  export async function withClaudeConfigLock<T>(
26
38
  configPath: string,
27
39
  run: () => Promise<T>,
40
+ options: ClaudeConfigLockOptions = {},
28
41
  ): Promise<T> {
29
42
  const lockPath = `${configPath}.lock`;
30
43
  const deadline = Date.now() + WAIT_MS;
@@ -34,6 +47,8 @@ export async function withClaudeConfigLock<T>(
34
47
  let held = await tryCreateLockDir(lockPath);
35
48
 
36
49
  while (held === null) {
50
+ options.onBusy?.();
51
+
37
52
  if (Date.now() > deadline) {
38
53
  throw new Error(`timed out waiting for the Claude config lock ${lockPath}`);
39
54
  }
@@ -43,21 +58,27 @@ export async function withClaudeConfigLock<T>(
43
58
  held = await tryCreateLockDir(lockPath);
44
59
  }
45
60
 
61
+ const clock = options.clock ?? systemClock;
46
62
  let owned: OwnedLock = held;
47
63
  let refreshing = Promise.resolve();
48
64
 
49
- const timer = setInterval(() => {
50
- refreshing = (async () => {
51
- const refreshed = await refreshOwnedLock(lockPath, owned);
65
+ const scheduleRefresh = (): (() => void) =>
66
+ clock.schedule(() => {
67
+ refreshing = (async () => {
68
+ const refreshed = await refreshOwnedLock(lockPath, owned, clock.now());
69
+
70
+ owned = refreshed ?? owned;
71
+ })();
72
+
73
+ stopRefreshing = scheduleRefresh();
74
+ }, REFRESH_MS);
52
75
 
53
- owned = refreshed ?? owned;
54
- })();
55
- }, REFRESH_MS);
76
+ let stopRefreshing = scheduleRefresh();
56
77
 
57
78
  try {
58
79
  return await run();
59
80
  } finally {
60
- clearInterval(timer);
81
+ stopRefreshing();
61
82
 
62
83
  await refreshing;
63
84
 
@@ -105,10 +126,14 @@ function isExistsError(error: unknown): boolean {
105
126
  return error instanceof Error && 'code' in error && error.code === 'EEXIST';
106
127
  }
107
128
 
108
- // Moves the lock's age forward while it is still the directory this call
109
- // created, and resolves to the lock as it then stands; null leaves the
110
- // lock to go stale, as a holder that stopped refreshing it does.
111
- async function refreshOwnedLock(lockPath: string, owned: OwnedLock): Promise<OwnedLock | null> {
129
+ // Moves the lock's age forward to the given time while it is still the
130
+ // directory this call created, and resolves to the lock as it then stands;
131
+ // null leaves the lock to go stale, as a holder that stopped refreshing it does.
132
+ async function refreshOwnedLock(
133
+ lockPath: string,
134
+ owned: OwnedLock,
135
+ nowMs: number,
136
+ ): Promise<OwnedLock | null> {
112
137
  const isOwned = await isOwnedLock(lockPath, owned);
113
138
 
114
139
  if (!isOwned) {
@@ -116,7 +141,7 @@ async function refreshOwnedLock(lockPath: string, owned: OwnedLock): Promise<Own
116
141
  }
117
142
 
118
143
  try {
119
- const now = new Date();
144
+ const now = new Date(nowMs);
120
145
 
121
146
  await utimes(lockPath, now, now);
122
147
 
@@ -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;
@@ -11,6 +11,8 @@ import { getBuild } from '../shared/get-build';
11
11
  import { isProcessAlive } from '../shared/is-process-alive';
12
12
  import { makeSingleFlight } from '../shared/make-single-flight';
13
13
  import { spawnATCDetached } from '../shared/spawn-atc-detached';
14
+ import { systemClock } from '../shared/system-clock';
15
+ import type { Clock } from '../shared/system-clock';
14
16
  import { toAgentID } from '../shared/to-agent-id';
15
17
  import { DaemonClient } from './daemon-client';
16
18
  import { formatProtocolMismatch } from './format-protocol-mismatch';
@@ -30,6 +32,17 @@ export interface DaemonBoot {
30
32
  readonly socketPath: string;
31
33
  }
32
34
 
35
+ /**
36
+ * The files that locate a running daemon: the socket this environment
37
+ * computes, the record the daemon writes in its state directory, and its
38
+ * pid file.
39
+ */
40
+ export interface DaemonPaths {
41
+ readonly socketPath: string;
42
+ readonly recordFile: string;
43
+ readonly pidFile: string;
44
+ }
45
+
33
46
  export interface DaemonBootOptions {
34
47
  // Called when the daemon speaks another protocol version. Resolving true
35
48
  // stops that daemon and boots one from this build, which ends every
@@ -42,8 +55,29 @@ export interface DaemonBootOptions {
42
55
  // manager starts beside a managed daemon sets it, so the two never race
43
56
  // for the state directory.
44
57
  readonly waitForDaemonMs?: number;
58
+
59
+ // Called once per boot, when a waiting boot first finds no daemon and
60
+ // starts to wait instead of starting one; a retry after a protocol
61
+ // mismatch that waits again does not call it a second time.
62
+ readonly onWaitForDaemon?: () => void;
63
+
64
+ // The time and the timers a waiting boot reads for its deadline and its
65
+ // polls; the wall clock when absent.
66
+ readonly clock?: Clock;
67
+
68
+ // Where the boot looks for a running daemon and its pid; this process's
69
+ // own paths when absent. `atc daemon` takes no paths of its own, only the
70
+ // environment's, so a boot given paths never starts a daemon: when none
71
+ // answers there, it rejects at once.
72
+ readonly paths?: DaemonPaths;
45
73
  }
46
74
 
75
+ const PROCESS_PATHS: DaemonPaths = {
76
+ socketPath: daemonSocketPath,
77
+ recordFile: daemonRecordFile,
78
+ pidFile: daemonPidFile,
79
+ };
80
+
47
81
  /**
48
82
  * Opens a handshaken client to the daemon, booting the daemon first when
49
83
  * neither the computed socket nor the one in the daemon's record answers,
@@ -59,14 +93,34 @@ export interface DaemonBootOptions {
59
93
  * its own boot would otherwise flag daemons that are already current.
60
94
  */
61
95
  export async function bootDaemonClient(options: DaemonBootOptions = {}): Promise<DaemonBoot> {
96
+ let waited = false;
97
+ const clock = options.clock ?? systemClock;
98
+ const paths = options.paths ?? PROCESS_PATHS;
99
+
62
100
  const wait =
63
101
  options.waitForDaemonMs === undefined
64
102
  ? null
65
- : { deadline: Date.now() + options.waitForDaemonMs, timeoutMs: options.waitForDaemonMs };
103
+ : {
104
+ deadline: clock.now() + options.waitForDaemonMs,
105
+ timeoutMs: options.waitForDaemonMs,
106
+ clock,
107
+ paths,
108
+ onWait: () => {
109
+ if (!waited) {
110
+ waited = true;
111
+ options.onWaitForDaemon?.();
112
+ }
113
+ },
114
+ };
66
115
 
67
116
  for (let attempt = 0; attempt < 2; attempt++) {
68
117
  const build = getBuild();
69
- const opened = wait === null ? await openOrBootDaemon() : await waitForDaemon(wait);
118
+
119
+ const opened =
120
+ wait === null
121
+ ? await openOrBootDaemon(paths, options.paths === undefined)
122
+ : await waitForDaemon(wait);
123
+
70
124
  const client = opened.client;
71
125
 
72
126
  try {
@@ -91,7 +145,7 @@ export async function bootDaemonClient(options: DaemonBootOptions = {}): Promise
91
145
 
92
146
  const mismatch: ProtocolMismatch = {
93
147
  socketPath: opened.socketPath,
94
- daemonPID: findDaemonPID(opened.socketPath),
148
+ daemonPID: findDaemonPID(opened.socketPath, paths),
95
149
  clientBuild: build,
96
150
  clientProtocol: PROTOCOL_V,
97
151
  daemonMessage: error.message,
@@ -119,19 +173,28 @@ interface OpenedDaemon {
119
173
  readonly socketPath: string;
120
174
  }
121
175
 
122
- async function openOrBootDaemon(): Promise<OpenedDaemon> {
123
- const opened = await tryOpenKnownDaemon();
176
+ /**
177
+ * Opens the daemon at the known paths, starting one first when none
178
+ * answers and the boot may start one. A boot that may not start one, since
179
+ * a daemon it started would listen elsewhere, rejects instead.
180
+ */
181
+ async function openOrBootDaemon(paths: DaemonPaths, canStart: boolean): Promise<OpenedDaemon> {
182
+ const opened = await tryOpenKnownDaemon(paths);
124
183
 
125
184
  if (opened !== null) {
126
185
  return opened;
127
186
  }
128
187
 
188
+ if (!canStart) {
189
+ throw new Error(formatGivenPathsFailure(paths));
190
+ }
191
+
129
192
  await bootDaemonOnce();
130
193
 
131
- const booted = await tryOpenKnownDaemon();
194
+ const booted = await tryOpenKnownDaemon(paths);
132
195
 
133
196
  if (booted === null) {
134
- throw new Error(formatBootFailure());
197
+ throw new Error(formatBootFailure(paths));
135
198
  }
136
199
 
137
200
  return booted;
@@ -142,24 +205,36 @@ async function openOrBootDaemon(): Promise<OpenedDaemon> {
142
205
  interface DaemonWait {
143
206
  readonly deadline: number;
144
207
  readonly timeoutMs: number;
208
+ readonly clock: Clock;
209
+ readonly paths: DaemonPaths;
210
+
211
+ // Called on every miss; the boot reports only the first to its caller.
212
+ readonly onWait: () => void;
145
213
  }
146
214
 
147
215
  /**
148
216
  * Polls the known sockets until a daemon answers, and never starts one.
217
+ * Each miss reports that the wait goes on.
149
218
  */
150
219
  async function waitForDaemon(wait: DaemonWait): Promise<OpenedDaemon> {
151
220
  for (;;) {
152
- const opened = await tryOpenKnownDaemon();
221
+ const opened = await tryOpenKnownDaemon(wait.paths);
153
222
 
154
223
  if (opened !== null) {
155
224
  return opened;
156
225
  }
157
226
 
158
- if (Date.now() >= wait.deadline) {
159
- throw new Error(formatWaitFailure(wait.timeoutMs));
227
+ wait.onWait();
228
+
229
+ if (wait.clock.now() >= wait.deadline) {
230
+ throw new Error(formatWaitFailure(wait));
160
231
  }
161
232
 
162
- await Bun.sleep(100);
233
+ const polled = Promise.withResolvers<void>();
234
+
235
+ wait.clock.schedule(polled.resolve, 100);
236
+
237
+ await polled.promise;
163
238
  }
164
239
  }
165
240
 
@@ -172,21 +247,19 @@ async function waitForHello(
172
247
  sendHello: () => Promise<Readonly<Record<string, unknown>>>,
173
248
  wait: DaemonWait,
174
249
  ): Promise<Readonly<Record<string, unknown>>> {
175
- let timer: ReturnType<typeof setTimeout> | undefined;
176
-
177
- const expired = new Promise<never>((_resolve, reject) => {
178
- timer = setTimeout(
179
- () => {
180
- reject(new Error(formatWaitFailure(wait.timeoutMs)));
181
- },
182
- Math.max(0, wait.deadline - Date.now()),
183
- );
184
- });
250
+ const expired = Promise.withResolvers<never>();
251
+
252
+ const cancel = wait.clock.schedule(
253
+ () => {
254
+ expired.reject(new Error(formatWaitFailure(wait)));
255
+ },
256
+ Math.max(0, wait.deadline - wait.clock.now()),
257
+ );
185
258
 
186
259
  try {
187
- return await Promise.race([sendHello(), expired]);
260
+ return await Promise.race([sendHello(), expired.promise]);
188
261
  } finally {
189
- clearTimeout(timer);
262
+ cancel();
190
263
  }
191
264
  }
192
265
 
@@ -195,16 +268,16 @@ async function waitForHello(
195
268
  * daemon recorded in the state directory: a client whose environment lacks
196
269
  * XDG_RUNTIME_DIR computes a different path from the daemon's.
197
270
  */
198
- async function tryOpenKnownDaemon(): Promise<OpenedDaemon | null> {
199
- const computed = await tryOpenDaemon(daemonSocketPath);
271
+ async function tryOpenKnownDaemon(paths: DaemonPaths): Promise<OpenedDaemon | null> {
272
+ const computed = await tryOpenDaemon(paths.socketPath);
200
273
 
201
274
  if (computed !== null) {
202
275
  return computed;
203
276
  }
204
277
 
205
- const record = findDaemonRecord(daemonRecordFile);
278
+ const record = findDaemonRecord(paths.recordFile);
206
279
 
207
- if (record === null || record.socketPath === daemonSocketPath) {
280
+ if (record === null || record.socketPath === paths.socketPath) {
208
281
  return null;
209
282
  }
210
283
 
@@ -230,7 +303,7 @@ const bootDaemonOnce = makeSingleFlight(async () => {
230
303
  while (Date.now() < deadline) {
231
304
  await Bun.sleep(100);
232
305
 
233
- const probe = await tryOpenKnownDaemon();
306
+ const probe = await tryOpenKnownDaemon(PROCESS_PATHS);
234
307
 
235
308
  if (probe !== null) {
236
309
  probe.client.stop();
@@ -249,14 +322,24 @@ const bootDaemonOnce = makeSingleFlight(async () => {
249
322
  // A live daemon this process cannot reach, such as one whose runtime
250
323
  // directory a sandbox hides, needs a different fix than one that never
251
324
  // started, so the message tells them apart.
252
- function formatBootFailure(): string {
325
+ function formatBootFailure(paths: DaemonPaths): string {
326
+ return (
327
+ formatUnreachableDaemon(paths) ??
328
+ 'the atc daemon did not come up; try `atc daemon` for its output'
329
+ );
330
+ }
331
+
332
+ // A boot given its own paths starts no daemon, since `atc daemon` would
333
+ // listen at this process's paths instead, so the message says so.
334
+ function formatGivenPathsFailure(paths: DaemonPaths): string {
253
335
  return (
254
- formatUnreachableDaemon() ?? 'the atc daemon did not come up; try `atc daemon` for its output'
336
+ formatUnreachableDaemon(paths) ??
337
+ `no atc daemon answered at ${paths.socketPath}, and a boot given its own daemon paths does not start one; start \`atc daemon\` where it listens there first`
255
338
  );
256
339
  }
257
340
 
258
- function formatUnreachableDaemon(): string | null {
259
- const record = findDaemonRecord(daemonRecordFile);
341
+ function formatUnreachableDaemon(paths: DaemonPaths): string | null {
342
+ const record = findDaemonRecord(paths.recordFile);
260
343
 
261
344
  if (record !== null && isProcessAlive(record.pid)) {
262
345
  return `the atc daemon (pid ${record.pid}) is running, but its socket ${record.socketPath} is unreachable from here`;
@@ -267,20 +350,20 @@ function formatUnreachableDaemon(): string | null {
267
350
 
268
351
  // A waiting caller was told not to start a daemon, so the message says so
269
352
  // and points at starting the managed one.
270
- function formatWaitFailure(timeoutMs: number): string {
353
+ function formatWaitFailure(wait: DaemonWait): string {
271
354
  return (
272
- formatUnreachableDaemon() ??
273
- `no atc daemon answered at ${daemonSocketPath} within ${timeoutMs / 1000}s, and this process does not start one; start \`atc daemon\` (or the service that runs it) first`
355
+ formatUnreachableDaemon(wait.paths) ??
356
+ `no atc daemon answered at ${wait.paths.socketPath} within ${wait.timeoutMs / 1000}s, and this process does not start one; start \`atc daemon\` (or the service that runs it) first`
274
357
  );
275
358
  }
276
359
 
277
360
  // The pid of the daemon behind the socket that refused the handshake.
278
- function findDaemonPID(socketPath: string): number | null {
361
+ function findDaemonPID(socketPath: string, paths: DaemonPaths): number | null {
279
362
  return pickStaleDaemonPID({
280
363
  socketPath,
281
- record: findDaemonRecord(daemonRecordFile),
282
- pidFileSocketPath: daemonSocketPath,
283
- pidFilePID: findPidFilePID(daemonPidFile),
364
+ record: findDaemonRecord(paths.recordFile),
365
+ pidFileSocketPath: paths.socketPath,
366
+ pidFilePID: findPidFilePID(paths.pidFile),
284
367
  });
285
368
  }
286
369
 
@@ -8,13 +8,15 @@ import { collectRootDirs } from '../shared/collect-root-dirs';
8
8
  * to find a directory: where the client runs, then the daemon's spawn
9
9
  * history (most recent first), then the configured roots, then zoxide's
10
10
  * frecency list. A path that no longer exists is dropped, a duplicate keeps
11
- * its first position, and an empty merge falls back to the home directory.
11
+ * its first position, and an empty merge falls back to the home directory:
12
+ * `home` when given, otherwise the account's.
12
13
  */
13
14
  export interface DirSources {
14
15
  readonly cwd: string;
15
16
  readonly recent: readonly string[];
16
17
  readonly roots: readonly string[];
17
18
  readonly zoxide: readonly string[];
19
+ readonly home?: string;
18
20
  }
19
21
 
20
22
  export function collectDirs(sources: DirSources): string[] {
@@ -35,7 +37,7 @@ export function collectDirs(sources: DirSources): string[] {
35
37
  }
36
38
 
37
39
  if (found.length === 0) {
38
- found.push(homedir());
40
+ found.push(sources.home ?? homedir());
39
41
  }
40
42
 
41
43
  return found;
@@ -7,7 +7,7 @@ import type { EventMsg } from '../protocol/protocol';
7
7
  import { sortGroupedSessionViews } from '../protocol/sort-grouped-session-views';
8
8
  import { sortSessionViews } from '../protocol/sort-session-views';
9
9
  import type { AgentID } from '../shared/agent-id';
10
- import { loadConfig } from '../shared/config';
10
+ import { configFile, loadConfig } from '../shared/config';
11
11
  import { makeSingleFlight } from '../shared/make-single-flight';
12
12
  import { bootDaemonClient } from './boot-daemon';
13
13
  import { buildClientMachine } from './build-client-machine';
@@ -16,6 +16,7 @@ import { collectTargetPicks } from './collect-target-picks';
16
16
  import { findFuzzyScore, formatDir } from './dirs';
17
17
  import type { ProtocolMismatch } from './format-protocol-mismatch';
18
18
  import { KEY, isDown, isUp, planTextEdit } from './keys';
19
+ import { logClientEvent } from './log-client-event';
19
20
  import { parseDaemonEvent } from './parse-daemon-event';
20
21
  import { pickTabTarget } from './pick-tab-target';
21
22
  import { resolveAgentMetadata } from './resolve-agent-metadata';
@@ -341,6 +342,15 @@ const picker = new SpawnPicker<MirrorSession>({
341
342
  toMirrorSession,
342
343
  upsertMirror,
343
344
  hasDaemonFeature,
345
+ write: (chunk) => {
346
+ process.stdout.write(chunk);
347
+ },
348
+ cwd: process.cwd(),
349
+ configPath: configFile,
350
+ onDropAnswer: (kind) => {
351
+ logClientEvent(`dropped ${kind} answer`);
352
+ },
353
+ log: logClientEvent,
344
354
  });
345
355
 
346
356
  function openPicker(resume: boolean) {
@@ -721,6 +731,12 @@ function applyOverlayKey(buf: Buffer) {
721
731
  return;
722
732
  }
723
733
 
734
+ if (ch === 'H') {
735
+ logClientEvent('ignored H on a session that cannot eject');
736
+
737
+ return;
738
+ }
739
+
724
740
  if (ch === 'P' && sel !== undefined && sel.resumable && (sel.kind === 'headless' || !sel.alive)) {
725
741
  void adoptSession(sel.id);
726
742
 
@@ -0,0 +1,20 @@
1
+ import { appendFileSync } from 'node:fs';
2
+
3
+ /**
4
+ * Appends one line, a decision the client took without drawing anything, to
5
+ * the file `ATC_CLIENT_LOG` holds, and does nothing when the variable is
6
+ * unset or empty. A write that fails is dropped, so the log never stops the
7
+ * client. It writes off the screen because the client's stdout and stderr
8
+ * are the terminal it draws on.
9
+ */
10
+ export function logClientEvent(line: string): void {
11
+ const path = process.env['ATC_CLIENT_LOG'];
12
+
13
+ if (path === undefined || path === '') {
14
+ return;
15
+ }
16
+
17
+ try {
18
+ appendFileSync(path, `${line}\n`);
19
+ } catch {}
20
+ }