@zgeoff/atc 2.10.0 → 2.10.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -93,7 +93,8 @@ list.
93
93
 
94
94
  If the daemon dies, press `R` on the home screen and every session respawns from its transcript.
95
95
  After you upgrade atc, the status bar shows `⟳ update ready`, and `u` restarts the daemon and
96
- restores the fleet when you are ready.
96
+ restores the fleet when you are ready. The bar shows `⟳ restarting daemon` until the restart
97
+ finishes.
97
98
 
98
99
  atc runs inside zellij or tmux. Give the pane locked mode so the leader key reaches atc.
99
100
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zgeoff/atc",
3
- "version": "2.10.0",
3
+ "version": "2.10.2",
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",
package/src/cli.ts CHANGED
@@ -191,24 +191,37 @@ const main = defineCommand({
191
191
  ),
192
192
  );
193
193
 
194
- const handle = await daemon.startDaemon({
195
- socketPath: config.daemonSocketPath,
196
- reporterSocketPath: config.socketPath,
197
- eventsSocketPath: config.eventsSocketPath,
198
- build: getBuild(),
199
- adapter: claudeAdapter,
200
- adapters: [claudeAdapter, grokAdapter, codexAdapter, ...gatewayAdapters],
201
- dbPath: config.dbFile,
202
- legacyFleetPath: config.legacyFleetFile,
203
- pidPath: config.daemonPidFile,
204
- hooks: cfg.hooks,
205
- restoreBootTimeoutMs,
206
- ...(Number.isFinite(graceOverride) && graceOverride >= 0
207
- ? { tapGraceMs: graceOverride }
208
- : {}),
209
- ...(Number.isFinite(queueBytes) && queueBytes > 0 ? { queueBytes } : {}),
210
- onQuit: () => process.exit(0),
211
- });
194
+ let handle: Awaited<ReturnType<typeof daemon.startDaemon>>;
195
+
196
+ try {
197
+ handle = await daemon.startDaemon({
198
+ socketPath: config.daemonSocketPath,
199
+ reporterSocketPath: config.socketPath,
200
+ eventsSocketPath: config.eventsSocketPath,
201
+ build: getBuild(),
202
+ adapter: claudeAdapter,
203
+ adapters: [claudeAdapter, grokAdapter, codexAdapter, ...gatewayAdapters],
204
+ dbPath: config.dbFile,
205
+ legacyFleetPath: config.legacyFleetFile,
206
+ pidPath: config.daemonPidFile,
207
+ hooks: cfg.hooks,
208
+ restoreBootTimeoutMs,
209
+ ...(Number.isFinite(graceOverride) && graceOverride >= 0
210
+ ? { tapGraceMs: graceOverride }
211
+ : {}),
212
+ ...(Number.isFinite(queueBytes) && queueBytes > 0 ? { queueBytes } : {}),
213
+ onQuit: () => process.exit(0),
214
+ });
215
+ } catch (error) {
216
+ // A second daemon on the same state directory refuses to start
217
+ // and touches nothing; any other startup failure stays a crash.
218
+ if (error instanceof Error && Reflect.get(error, 'code') === 'daemon_locked') {
219
+ console.error(error.message);
220
+ process.exit(1);
221
+ }
222
+
223
+ throw error;
224
+ }
212
225
 
213
226
  process.on('SIGTERM', () => {
214
227
  void (async () => {
@@ -1,23 +1,32 @@
1
1
  import { spawn as spawnChild } from 'node:child_process';
2
+ import type { ChildProcess } from 'node:child_process';
2
3
  import { readFileSync } from 'node:fs';
3
4
  import { join } from 'node:path';
4
5
  import { toAgentID } from '../agents/agent-adapter';
5
6
  import type { AgentID } from '../agents/agent-adapter';
6
- import { daemonPidFile, daemonSocketPath } from '../shared/config';
7
+ import { daemonPidFile, daemonRecordFile, daemonSocketPath } from '../shared/config';
8
+ import { findDaemonRecord } from '../shared/find-daemon-record';
7
9
  import { getBuild } from '../shared/get-build';
8
10
  import { isCompiledBinary } from '../shared/is-compiled-binary';
11
+ import { makeSingleFlight } from '../shared/make-single-flight';
9
12
  import { isRecord } from '../shared/report';
10
13
  import { DaemonClient } from './daemon-client';
14
+ import { pickStaleDaemonPID } from './pick-stale-daemon-pid';
11
15
 
12
16
  export interface DaemonBoot {
13
17
  readonly client: DaemonClient;
14
18
  readonly stale: boolean;
15
19
  readonly lastUsedAgent: AgentID;
20
+
21
+ // The socket the client reached, which differs from the computed one when
22
+ // the daemon was found through its record in the state directory.
23
+ readonly socketPath: string;
16
24
  }
17
25
 
18
26
  /**
19
- * Opens a handshaken client to the daemon, booting the daemon first when its
20
- * socket is absent. A daemon from an older build stays in service — killing
27
+ * Opens a handshaken client to the daemon, booting the daemon first when
28
+ * neither the computed socket nor the one in the daemon's record answers.
29
+ * Overlapping calls in one process share a single boot. A daemon from an older build stays in service — killing
21
30
  * it would kill every hosted session — and is reported as stale so the
22
31
  * caller can offer a deliberate restart. Only a protocol mismatch, where
23
32
  * talking would misbehave, forces the restart immediately. The expected
@@ -29,7 +38,9 @@ export async function bootDaemonClient(): Promise<DaemonBoot> {
29
38
  for (let attempt = 0; attempt < 2; attempt++) {
30
39
  const build = getBuild();
31
40
 
32
- const client = await openOrBootDaemon();
41
+ const opened = await openOrBootDaemon();
42
+
43
+ const client = opened.client;
33
44
 
34
45
  try {
35
46
  const hello = await client.sendHello(build);
@@ -38,6 +49,7 @@ export async function bootDaemonClient(): Promise<DaemonBoot> {
38
49
  client,
39
50
  stale: hello['daemon'] !== build,
40
51
  lastUsedAgent: toAgentID(hello['lastUsedAgent']),
52
+ socketPath: opened.socketPath,
41
53
  };
42
54
  } catch (error) {
43
55
  client.stop();
@@ -46,7 +58,7 @@ export async function bootDaemonClient(): Promise<DaemonBoot> {
46
58
  throw error;
47
59
  }
48
60
 
49
- await stopStaleDaemon();
61
+ await stopStaleDaemon(opened.socketPath);
50
62
  }
51
63
  }
52
64
 
@@ -57,46 +69,117 @@ function isProtocolMismatch(error: unknown): boolean {
57
69
  return isRecord(error) && error['code'] === 'protocol_mismatch';
58
70
  }
59
71
 
60
- async function openOrBootDaemon(): Promise<DaemonClient> {
61
- let opened = await tryOpenDaemon();
72
+ interface OpenedDaemon {
73
+ readonly client: DaemonClient;
74
+ readonly socketPath: string;
75
+ }
62
76
 
63
- if (opened === null) {
64
- spawnDaemonDetached();
77
+ async function openOrBootDaemon(): Promise<OpenedDaemon> {
78
+ const opened = await tryOpenKnownDaemon();
65
79
 
66
- const deadline = Date.now() + 5000;
80
+ if (opened !== null) {
81
+ return opened;
82
+ }
67
83
 
68
- while (opened === null && Date.now() < deadline) {
69
- await Bun.sleep(100);
84
+ await bootDaemonOnce();
70
85
 
71
- opened = await tryOpenDaemon();
72
- }
86
+ const booted = await tryOpenKnownDaemon();
87
+
88
+ if (booted === null) {
89
+ throw new Error(formatBootFailure());
90
+ }
91
+
92
+ return booted;
93
+ }
94
+
95
+ /**
96
+ * Tries the socket this environment computes, then the one the running
97
+ * daemon recorded in the state directory: a client whose environment lacks
98
+ * XDG_RUNTIME_DIR computes a different path from the daemon's.
99
+ */
100
+ async function tryOpenKnownDaemon(): Promise<OpenedDaemon | null> {
101
+ const computed = await tryOpenDaemon(daemonSocketPath);
102
+
103
+ if (computed !== null) {
104
+ return computed;
73
105
  }
74
106
 
75
- if (opened === null) {
76
- throw new Error('the atc daemon did not come up; try `atc daemon` for its output');
107
+ const record = findDaemonRecord(daemonRecordFile);
108
+
109
+ if (record === null || record.socketPath === daemonSocketPath) {
110
+ return null;
77
111
  }
78
112
 
79
- return opened;
113
+ return tryOpenDaemon(record.socketPath);
80
114
  }
81
115
 
82
- async function tryOpenDaemon(): Promise<DaemonClient | null> {
116
+ async function tryOpenDaemon(socketPath: string): Promise<OpenedDaemon | null> {
83
117
  try {
84
- return await DaemonClient.open(daemonSocketPath);
118
+ return { client: await DaemonClient.open(socketPath), socketPath };
85
119
  } catch {
86
120
  return null;
87
121
  }
88
122
  }
89
123
 
90
- async function stopStaleDaemon(): Promise<void> {
91
- let pid = 0;
124
+ // How long a boot waits for a daemon to answer. It covers a daemon that
125
+ // waits out the state lock of one still shutting down.
126
+ const BOOT_DEADLINE_MS = 8000;
127
+
128
+ const bootDaemonOnce = makeSingleFlight(async () => {
129
+ let child = spawnDaemonDetached();
130
+ const deadline = Date.now() + BOOT_DEADLINE_MS;
131
+
132
+ while (Date.now() < deadline) {
133
+ await Bun.sleep(100);
134
+
135
+ const probe = await tryOpenKnownDaemon();
136
+
137
+ if (probe !== null) {
138
+ probe.client.stop();
139
+
140
+ return;
141
+ }
142
+
143
+ // A daemon that found the state lock still held by one shutting down
144
+ // exits; once the old one is gone, a fresh spawn takes the lock.
145
+ if (child.exitCode !== null || child.signalCode !== null) {
146
+ child = spawnDaemonDetached();
147
+ }
148
+ }
149
+ });
150
+
151
+ // A live daemon this process cannot reach, such as one whose runtime
152
+ // directory a sandbox hides, needs a different fix than one that never
153
+ // started, so the message tells them apart.
154
+ function formatBootFailure(): string {
155
+ const record = findDaemonRecord(daemonRecordFile);
92
156
 
157
+ if (record !== null && isProcessAlive(record.pid)) {
158
+ return `the atc daemon (pid ${record.pid}) is running, but its socket ${record.socketPath} is unreachable from here`;
159
+ }
160
+
161
+ return 'the atc daemon did not come up; try `atc daemon` for its output';
162
+ }
163
+
164
+ function isProcessAlive(pid: number): boolean {
93
165
  try {
94
- pid = Number(readFileSync(daemonPidFile, 'utf8'));
166
+ process.kill(pid, 0);
167
+
168
+ return true;
95
169
  } catch {
96
- return;
170
+ return false;
97
171
  }
172
+ }
173
+
174
+ async function stopStaleDaemon(socketPath: string): Promise<void> {
175
+ const pid = pickStaleDaemonPID({
176
+ socketPath,
177
+ record: findDaemonRecord(daemonRecordFile),
178
+ pidFileSocketPath: daemonSocketPath,
179
+ pidFilePID: findPidFilePID(),
180
+ });
98
181
 
99
- if (!Number.isInteger(pid) || pid <= 1) {
182
+ if (pid === null) {
100
183
  return;
101
184
  }
102
185
 
@@ -119,8 +202,22 @@ async function stopStaleDaemon(): Promise<void> {
119
202
  }
120
203
  }
121
204
 
122
- function spawnDaemonDetached() {
205
+ // The pid file beside the sockets this environment computes.
206
+ function findPidFilePID(): number | null {
207
+ try {
208
+ const pid = Number(readFileSync(daemonPidFile, 'utf8'));
209
+
210
+ return Number.isInteger(pid) && pid > 1 ? pid : null;
211
+ } catch {
212
+ return null;
213
+ }
214
+ }
215
+
216
+ function spawnDaemonDetached(): ChildProcess {
123
217
  const args = isCompiledBinary() ? ['daemon'] : [join(import.meta.dir, '..', 'cli.ts'), 'daemon'];
218
+ const child = spawnChild(process.execPath, args, { detached: true, stdio: 'ignore' });
219
+
220
+ child.unref();
124
221
 
125
- spawnChild(process.execPath, args, { detached: true, stdio: 'ignore' }).unref();
222
+ return child;
126
223
  }
@@ -4,6 +4,7 @@ import type { AgentID } from '../agents/agent-adapter';
4
4
  import { countSessionStates, sortGroupedSessionViews, sortSessionViews } from '../daemon/sessions';
5
5
  import type { EventMsg } from '../protocol/protocol';
6
6
  import { loadConfig } from '../shared/config';
7
+ import { makeSingleFlight } from '../shared/make-single-flight';
7
8
  import { bootDaemonClient } from './boot-daemon';
8
9
  import { buildClientMachine } from './build-client-machine';
9
10
  import { buildLeaderChords } from './build-leader-chords';
@@ -82,6 +83,7 @@ function scheduleStatus() {
82
83
  urgentName: urgent === undefined ? null : urgent.name,
83
84
  leaderLabel: leader.label,
84
85
  stale: daemonStale,
86
+ restarting: daemonRestarting,
85
87
  });
86
88
  }
87
89
  }, 50);
@@ -214,7 +216,7 @@ function renderOverlay() {
214
216
  selected: overlaySelected,
215
217
  confirmKill,
216
218
  filter: overlayFilter,
217
- stale: daemonStale,
219
+ stale: daemonStale && !daemonRestarting,
218
220
  grouped: overlayGrouped,
219
221
  });
220
222
 
@@ -616,7 +618,7 @@ function applyOverlayKey(buf: Buffer) {
616
618
  }
617
619
 
618
620
  if (ch === 'u' && daemonStale) {
619
- void restartDaemon();
621
+ void restartDaemonOnce();
620
622
 
621
623
  return;
622
624
  }
@@ -734,6 +736,7 @@ const boot = await bootDaemonClient();
734
736
 
735
737
  let client = boot.client;
736
738
  let daemonStale = boot.stale;
739
+ let daemonRestarting = false;
737
740
 
738
741
  lastUsedAgent = boot.lastUsedAgent;
739
742
  client.onEvent = applyDaemonEvent;
@@ -767,8 +770,24 @@ async function refreshMirror() {
767
770
  /**
768
771
  * The deliberate half of the stale-daemon story: quits the old daemon,
769
772
  * boots one from the current build, and restores the whole fleet, so an
770
- * update never interrupts sessions until the user picks the moment.
773
+ * update never interrupts sessions until the user picks the moment. A
774
+ * press while a restart runs joins it rather than starting another, and
775
+ * the status bar says a restart is running until it finishes.
771
776
  */
777
+ const restartDaemonOnce = makeSingleFlight(async () => {
778
+ daemonRestarting = true;
779
+
780
+ renderOverlay();
781
+
782
+ try {
783
+ await restartDaemon();
784
+ } finally {
785
+ daemonRestarting = false;
786
+
787
+ scheduleStatus();
788
+ }
789
+ });
790
+
772
791
  async function restartDaemon() {
773
792
  try {
774
793
  await client.sendRequest('daemon.quit');
@@ -0,0 +1,31 @@
1
+ import type { DaemonRecord } from '../shared/find-daemon-record';
2
+
3
+ export interface StaleDaemonEvidence {
4
+ // The socket whose daemon refused the handshake.
5
+ readonly socketPath: string;
6
+ readonly record: DaemonRecord | null;
7
+
8
+ // The socket the pid file sits beside, and the pid it holds.
9
+ readonly pidFileSocketPath: string;
10
+ readonly pidFilePID: number | null;
11
+ }
12
+
13
+ /**
14
+ * Picks the pid of the daemon behind the socket that refused the
15
+ * handshake, from whichever source belongs to that socket: the state
16
+ * directory's record when it lists that socket, else the pid file beside
17
+ * it. A record for any other socket belongs to a different daemon, so it
18
+ * never supplies the pid; with no source for that socket, there is no pid
19
+ * to stop.
20
+ */
21
+ export function pickStaleDaemonPID(evidence: StaleDaemonEvidence): number | null {
22
+ if (evidence.record !== null && evidence.record.socketPath === evidence.socketPath) {
23
+ return evidence.record.pid;
24
+ }
25
+
26
+ if (evidence.socketPath === evidence.pidFileSocketPath) {
27
+ return evidence.pidFilePID;
28
+ }
29
+
30
+ return null;
31
+ }
package/src/client/ui.ts CHANGED
@@ -75,6 +75,7 @@ export interface StatusView {
75
75
  readonly urgentName: string | null;
76
76
  readonly leaderLabel: string;
77
77
  readonly stale: boolean;
78
+ readonly restarting: boolean;
78
79
  }
79
80
 
80
81
  export function drawStatusBar(view: StatusView) {
@@ -101,7 +102,9 @@ export function drawStatusBar(view: StatusView) {
101
102
  parts.push(`✗ ${c.exited}`);
102
103
  }
103
104
 
104
- if (view.stale) {
105
+ if (view.restarting) {
106
+ parts.push('⟳ restarting daemon');
107
+ } else if (view.stale) {
105
108
  parts.push('⟳ update ready');
106
109
  }
107
110
 
@@ -0,0 +1,76 @@
1
+ import { FFIType, dlopen, ptr } from 'bun:ffi';
2
+ import { closeSync, writeFileSync } from 'node:fs';
3
+
4
+ export interface DaemonLock {
5
+ // Releases the lock; safe to call more than once.
6
+ readonly dispose: () => void;
7
+ }
8
+
9
+ const LOCK_EX = 2;
10
+ const LOCK_NB = 4;
11
+ const O_RDWR = 2;
12
+
13
+ // Kept off every child the daemon spawns, so an orphaned agent process can
14
+ // never hold a dead daemon's lock.
15
+ const O_CLOEXEC = process.platform === 'darwin' ? 0x01_00_00_00 : 0o200_0000;
16
+ const RETRY_MS = 50;
17
+
18
+ /**
19
+ * Takes the exclusive daemon lock on the file at `lockPath`, waiting up to
20
+ * `waitMs` for a daemon that is shutting down to let go, and resolves null
21
+ * while another daemon holds it.
22
+ *
23
+ * The lock is a kernel `flock` on an open file description: two daemons
24
+ * racing at the same instant cannot both win, and the kernel drops it when
25
+ * the holding process dies, so a crashed daemon never blocks the next one.
26
+ * The file is never removed: removing it would let a newcomer lock a fresh
27
+ * inode while an older daemon still holds the unlinked one.
28
+ */
29
+ export async function claimDaemonLock(
30
+ lockPath: string,
31
+ waitMs: number,
32
+ ): Promise<DaemonLock | null> {
33
+ writeFileSync(lockPath, '', { flag: 'a' });
34
+
35
+ const libc = openLibc();
36
+ const fd = libc.symbols.open(ptr(Buffer.from(`${lockPath}\0`)), O_RDWR | O_CLOEXEC);
37
+
38
+ if (fd < 0) {
39
+ throw new Error(`atc daemon: cannot open the lock file ${lockPath}`);
40
+ }
41
+
42
+ const deadline = Date.now() + waitMs;
43
+
44
+ while (libc.symbols.flock(fd, LOCK_EX | LOCK_NB) !== 0) {
45
+ if (Date.now() >= deadline) {
46
+ closeSync(fd);
47
+
48
+ return null;
49
+ }
50
+
51
+ await Bun.sleep(RETRY_MS);
52
+ }
53
+
54
+ let held = true;
55
+
56
+ return {
57
+ dispose: () => {
58
+ if (held) {
59
+ held = false;
60
+
61
+ closeSync(fd);
62
+ }
63
+ },
64
+ };
65
+ }
66
+
67
+ // `open` is variadic in C; it is declared here with two arguments, which is
68
+ // sound because the lock file already exists and no mode is passed.
69
+ function openLibc() {
70
+ const libcPath = process.platform === 'darwin' ? '/usr/lib/libSystem.B.dylib' : 'libc.so.6';
71
+
72
+ return dlopen(libcPath, {
73
+ open: { args: [FFIType.ptr, FFIType.i32], returns: FFIType.i32 },
74
+ flock: { args: [FFIType.i32, FFIType.i32], returns: FFIType.i32 },
75
+ });
76
+ }
@@ -1,8 +1,10 @@
1
1
  import { unlinkSync, writeFileSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
2
3
  import type { AdapterEvent, AgentAdapter } from '../agents/agent-adapter';
3
4
  import { MAX_CHUNK, PROTOCOL_V } from '../protocol/protocol';
4
5
  import type { EventMsg } from '../protocol/protocol';
5
6
  import type { HooksConfig } from '../shared/collect-hooks';
7
+ import { findDaemonRecord } from '../shared/find-daemon-record';
6
8
  import type { SessionID } from '../shared/session-id';
7
9
  import { truncateToBytes } from '../shared/truncate-to-bytes';
8
10
  import type { MessageOwner } from '../store/message-owner';
@@ -17,6 +19,7 @@ import { buildReportTrailEntry } from './build-report-trail-entry';
17
19
  import { buildSessionEvent } from './build-session-event';
18
20
  import { buildSessionMessageEvent } from './build-session-message-event';
19
21
  import { buildSessionReportEvent } from './build-session-report-event';
22
+ import { claimDaemonLock } from './claim-daemon-lock';
20
23
  import { DaemonConnection } from './daemon-connection';
21
24
  import type { DaemonContext, OutputClient, TapClient } from './daemon-connection';
22
25
  import { EventSignal } from './event-signal';
@@ -37,6 +40,7 @@ import type { Session, SessionDescriptor, SessionState } from './sessions';
37
40
  import { startEventsServer } from './start-events-server';
38
41
  import { startHeadlessTurn } from './start-headless-turn';
39
42
  import { TapRegistry } from './tap-registry';
43
+ import { writeDaemonRecord } from './write-daemon-record';
40
44
 
41
45
  export interface DaemonOptions {
42
46
  readonly socketPath: string;
@@ -101,6 +105,10 @@ export interface DaemonHandle {
101
105
  // it is refused.
102
106
  const TAP_GRACE_MS = 15_000;
103
107
 
108
+ // How long startup waits for a daemon that is shutting down to release the
109
+ // state lock before refusing to start.
110
+ const LOCK_WAIT_MS = 2000;
111
+
104
112
  /**
105
113
  * The daemon: owns the sessions, the client-protocol listener, and the
106
114
  * reporter listener. Protocol requests are NDJSON lines, one response per
@@ -113,6 +121,23 @@ const TAP_GRACE_MS = 15_000;
113
121
  export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
114
122
  let stopDaemon: (() => Promise<void>) | null = null;
115
123
 
124
+ // One daemon per state directory: the lock comes before the store, the
125
+ // sockets, or the fleet, so a second daemon touches none of them.
126
+ const stateDir = dirname(opts.dbPath);
127
+ const recordPath = join(stateDir, 'daemon.json');
128
+
129
+ const lock = await claimDaemonLock(join(stateDir, 'daemon.lock'), LOCK_WAIT_MS);
130
+
131
+ if (lock === null) {
132
+ const holder = findDaemonRecord(recordPath);
133
+ const where = holder === null ? '' : ` (pid ${holder.pid}, socket ${holder.socketPath})`;
134
+
135
+ throw Object.assign(
136
+ new Error(`atc daemon: another daemon already serves ${stateDir}${where}`),
137
+ { code: 'daemon_locked' },
138
+ );
139
+ }
140
+
116
141
  if (opts.pidPath !== undefined) {
117
142
  writeFileSync(opts.pidPath, String(process.pid));
118
143
  }
@@ -1068,13 +1093,26 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
1068
1093
 
1069
1094
  await store.stop();
1070
1095
 
1096
+ try {
1097
+ unlinkSync(recordPath);
1098
+ } catch {}
1099
+
1071
1100
  if (opts.pidPath !== undefined) {
1072
1101
  try {
1073
1102
  unlinkSync(opts.pidPath);
1074
1103
  } catch {}
1075
1104
  }
1105
+
1106
+ lock.dispose();
1076
1107
  };
1077
1108
 
1109
+ writeDaemonRecord(recordPath, {
1110
+ pid: process.pid,
1111
+ socketPath: opts.socketPath,
1112
+ reporterSocketPath: opts.reporterSocketPath,
1113
+ eventsSocketPath: opts.eventsSocketPath ?? null,
1114
+ });
1115
+
1078
1116
  return { stop: stopDaemon, countClients: () => clients.size };
1079
1117
  }
1080
1118
 
@@ -1,4 +1,3 @@
1
- import { query } from '@anthropic-ai/claude-agent-sdk';
2
1
  import type { AgentSessionID } from '../shared/agent-session-id';
3
2
  import { socketPath } from '../shared/config';
4
3
  import { isCompiledBinary } from '../shared/is-compiled-binary';
@@ -55,7 +54,12 @@ export function startHeadlessRun(
55
54
  const mode = PERMISSION_MODES.find((m) => m === opts.permissionMode);
56
55
  let stderrTail = '';
57
56
 
58
- const stream = query({
57
+ // The SDK is the heaviest module in the daemon's graph, and only a
58
+ // headless turn needs it, so loading it here keeps it off the daemon's
59
+ // cold start.
60
+ const sdk = await import('@anthropic-ai/claude-agent-sdk');
61
+
62
+ const stream = sdk.query({
59
63
  prompt: opts.prompt,
60
64
  options: {
61
65
  cwd: opts.cwd,
@@ -0,0 +1,13 @@
1
+ import { renameSync, writeFileSync } from 'node:fs';
2
+ import type { DaemonRecord } from '../shared/find-daemon-record';
3
+
4
+ /**
5
+ * Writes the running daemon's record through a temporary file and a
6
+ * rename, so a reader never sees half of it.
7
+ */
8
+ export function writeDaemonRecord(recordPath: string, record: DaemonRecord): void {
9
+ const tmpPath = `${recordPath}.${process.pid}.tmp`;
10
+
11
+ writeFileSync(tmpPath, `${JSON.stringify(record)}\n`);
12
+ renameSync(tmpPath, recordPath);
13
+ }
@@ -4,7 +4,7 @@ import { collectClients } from './mcp/collect-clients';
4
4
  import { openMCPAuth } from './mcp/open-mcp-auth';
5
5
  import { ReconnectingCaller } from './mcp/reconnecting-caller';
6
6
  import { startMCPHTTPServer } from './mcp/start-mcp-http-server';
7
- import { daemonSocketPath, mcpAuthDBFile, stateDir } from './shared/config';
7
+ import { mcpAuthDBFile, stateDir } from './shared/config';
8
8
  import { loadMCPHTTPConfig } from './shared/load-mcp-http-config';
9
9
 
10
10
  interface MCPHTTPFlags {
@@ -27,7 +27,7 @@ export async function runMCPHTTPServer(build: string, flags: MCPHTTPFlags): Prom
27
27
 
28
28
  mkdirSync(stateDir, { recursive: true });
29
29
 
30
- const caller = new ReconnectingCaller(daemonSocketPath, build);
30
+ const caller = new ReconnectingCaller(boot.socketPath, build);
31
31
 
32
32
  const server = await startMCPHTTPServer({
33
33
  caller,
@@ -62,6 +62,10 @@ export const mcpAuthDBFile = join(stateDir, 'mcp-auth.db');
62
62
  export const legacyFleetFile = join(stateDir, 'fleet.json');
63
63
  export const daemonPidFile = join(process.env['XDG_RUNTIME_DIR'] ?? stateDir, 'atc-daemon.pid');
64
64
 
65
+ // Written by the running daemon: its pid and the socket paths it listens
66
+ // on, for a client whose environment computes other socket paths.
67
+ export const daemonRecordFile = join(stateDir, 'daemon.json');
68
+
65
69
  // A user-written config.json's top-level keys. An absent or wrong-typed
66
70
  // field parses to undefined rather than failing the file, so a bad config
67
71
  // falls back to a default instead of refusing to start atc.
@@ -0,0 +1,37 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { z } from 'zod';
3
+
4
+ export interface DaemonRecord {
5
+ readonly pid: number;
6
+ readonly socketPath: string;
7
+ readonly reporterSocketPath: string;
8
+ readonly eventsSocketPath: string | null;
9
+ }
10
+
11
+ const RECORD_SCHEMA = z.object({
12
+ pid: z.number().int().positive(),
13
+ socketPath: z.string().min(1),
14
+ reporterSocketPath: z.string().min(1),
15
+ eventsSocketPath: z.string().min(1).nullable(),
16
+ });
17
+
18
+ /**
19
+ * Reads the record a running daemon keeps in its state directory: its pid
20
+ * and the socket paths it listens on, which a client whose environment
21
+ * computes other socket paths uses to find it. A missing, torn, or
22
+ * malformed record reads as null; a record left by a crashed daemon still
23
+ * parses, so a caller proves liveness by connecting.
24
+ */
25
+ export function findDaemonRecord(recordPath: string): DaemonRecord | null {
26
+ let raw: unknown;
27
+
28
+ try {
29
+ raw = JSON.parse(readFileSync(recordPath, 'utf8'));
30
+ } catch {
31
+ return null;
32
+ }
33
+
34
+ const parsed = RECORD_SCHEMA.safeParse(raw);
35
+
36
+ return parsed.success ? parsed.data : null;
37
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Wraps an async function so overlapping calls share one run: a call made
3
+ * while a run is in flight gets that run's promise instead of starting
4
+ * another, and the first call after it settles starts a fresh run.
5
+ */
6
+ export function makeSingleFlight<T>(run: () => Promise<T>): () => Promise<T> {
7
+ let inFlight: Promise<T> | null = null;
8
+
9
+ const runAndClear = async (): Promise<T> => {
10
+ try {
11
+ return await run();
12
+ } finally {
13
+ inFlight = null;
14
+ }
15
+ };
16
+
17
+ return () => {
18
+ inFlight ??= runAndClear();
19
+
20
+ return inFlight;
21
+ };
22
+ }