@zgeoff/atc 2.20.0 → 2.22.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 (57) hide show
  1. package/package.json +1 -1
  2. package/src/cli.ts +187 -118
  3. package/src/client/daemon-client.ts +29 -18
  4. package/src/daemon/build-payload-hash.ts +4 -3
  5. package/src/daemon/daemon-connection.ts +270 -20
  6. package/src/daemon/daemon-context.ts +8 -0
  7. package/src/daemon/daemon.ts +186 -32
  8. package/src/daemon/find-token-fingerprint.ts +23 -0
  9. package/src/daemon/handshake-throttle.ts +47 -0
  10. package/src/daemon/idempotency-ledger.ts +20 -2
  11. package/src/daemon/is-allowed-listen-host.ts +63 -0
  12. package/src/daemon/load-listener-tokens.ts +46 -0
  13. package/src/daemon/parse-listen-address.ts +33 -0
  14. package/src/daemon/start-tcp-listener.ts +165 -0
  15. package/src/federation/build-binding-payload-hash.ts +34 -0
  16. package/src/federation/build-daemon-outdated-error.ts +14 -0
  17. package/src/federation/build-events-filter-hash.ts +16 -0
  18. package/src/federation/build-gateway-error.ts +45 -0
  19. package/src/federation/build-gateway-id.ts +14 -0
  20. package/src/federation/build-gateway-result.ts +31 -0
  21. package/src/federation/build-ruled-value.ts +53 -0
  22. package/src/federation/collect-unruled-id-paths.ts +46 -0
  23. package/src/federation/daemon-caller.ts +473 -0
  24. package/src/federation/daemon-pool.ts +56 -0
  25. package/src/federation/decode-gateway-cursor.ts +73 -0
  26. package/src/federation/encode-gateway-cursor.ts +15 -0
  27. package/src/federation/gateway-error.ts +25 -0
  28. package/src/federation/gateway-store.ts +253 -0
  29. package/src/federation/id-rules.ts +85 -0
  30. package/src/federation/load-gateway-registry.ts +29 -0
  31. package/src/federation/max-events-cursor-bytes.ts +4 -0
  32. package/src/federation/max-registry-daemons.ts +26 -0
  33. package/src/federation/merge-event-pages.ts +228 -0
  34. package/src/federation/open-gateway-caller.ts +55 -0
  35. package/src/federation/parse-gateway-id.ts +28 -0
  36. package/src/federation/parse-gateway-registry.ts +123 -0
  37. package/src/federation/pick-daemon-state.ts +46 -0
  38. package/src/federation/plan-event-reads.ts +54 -0
  39. package/src/federation/read-fleet-events.ts +279 -0
  40. package/src/federation/require-serving-daemon.ts +27 -0
  41. package/src/federation/resolve-daemon-request.ts +59 -0
  42. package/src/federation/routing-caller.ts +450 -0
  43. package/src/federation/types.ts +33 -0
  44. package/src/federation/wait-for-outcome.ts +38 -0
  45. package/src/mcp/answer-rpc-request.ts +14 -1
  46. package/src/mcp/build-tool-list.ts +6 -5
  47. package/src/mcp/mcp-tools.ts +41 -8
  48. package/src/mcp/require-daemon-features.ts +2 -0
  49. package/src/mcp/run-tool.ts +15 -1
  50. package/src/mcp/start-mcp-http-server.ts +53 -9
  51. package/src/mcp/types.ts +8 -1
  52. package/src/protocol/daemon-features.ts +9 -0
  53. package/src/protocol/protocol.ts +1 -0
  54. package/src/protocol/request-param-schemas.ts +6 -0
  55. package/src/run-daemon-id.ts +52 -0
  56. package/src/shared/find-daemon-record.ts +8 -3
  57. package/src/store/state-store.ts +16 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zgeoff/atc",
3
- "version": "2.20.0",
3
+ "version": "2.22.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",
package/src/cli.ts CHANGED
@@ -3,10 +3,23 @@
3
3
  import { defineCommand, runMain } from 'citty';
4
4
  import pkg from '../package.json';
5
5
  import { collectRedirectURIs } from './collect-redirect-uris';
6
+ import { parseListenAddress } from './daemon/parse-listen-address';
6
7
  import { normalizeCLIArgs } from './normalize-cli-args';
7
8
  import { parsePort } from './parse-port';
8
9
  import { getBuild } from './shared/get-build';
9
10
 
11
+ // The flags `atc daemon` takes.
12
+ const DAEMON_ARGS = {
13
+ listen: {
14
+ type: 'string',
15
+ description: 'Serve the client protocol on <host>:<port> too, a loopback or tailnet address',
16
+ },
17
+ 'token-file': {
18
+ type: 'string',
19
+ description: 'File holding the one or two bearer tokens a --listen handshake must present',
20
+ },
21
+ } as const;
22
+
10
23
  const main = defineCommand({
11
24
  meta: {
12
25
  name: 'atc',
@@ -152,127 +165,36 @@ const main = defineCommand({
152
165
  name: 'daemon',
153
166
  description: 'Run the atc daemon in the foreground',
154
167
  },
155
- async run() {
156
- const daemon = await import('./daemon/daemon');
157
- const config = await import('./shared/config');
158
- const claude = await import('./agents/claude-adapter');
159
- const grok = await import('./agents/grok-adapter');
160
- const codex = await import('./agents/codex-adapter');
161
- const gateway = await import('./agents/gateway-adapter');
162
- const headless = await import('./agents/start-claude-headless-run');
163
- const targets = await import('./daemon/build-execution-targets');
164
- const sourceOrder = await import('./sources/build-sources');
165
- const builtinSources = await import('./sources/collect-builtin-sources');
166
- const zoxide = await import('./shared/collect-zoxide-dirs');
167
- const home = await import('./shared/resolve-home-dir');
168
-
169
- // Test harnesses shrink the outbound queue to force overflow
170
- // deterministically; unset means the production default.
171
- const queueBytes = Number(process.env['ATC_QUEUE_BYTES']);
172
- const cfg = config.loadConfig();
173
- const built = targets.buildExecutionTargets(cfg.targets);
174
- const targetErrors = [...cfg.targetErrors, ...built.errors];
175
-
176
- for (const error of targetErrors) {
177
- const line =
178
- error.scope === 'config'
179
- ? `${error.path} cannot be used (${error.problem}: ${error.detail}); every spawn is refused, local ones included, until it is fixed`
180
- : error.problem;
181
-
182
- console.error(`atc daemon: config: ${line}`);
183
- }
184
-
185
- for (const problem of [
186
- ...cfg.principalErrors,
187
- ...cfg.workspaceErrors,
188
- ...cfg.authProfileErrors,
189
- ...cfg.gatewayErrors,
190
- ]) {
191
- console.error(`atc daemon: config: ${problem}`);
192
- }
193
168
 
194
- const sources = sourceOrder.buildSources(
195
- builtinSources.collectBuiltinSources({
196
- roots: cfg.dirs.roots,
197
- githubOwner: cfg.workspaces.githubOwner,
198
- ghBin: 'gh',
199
- homeDir: home.resolveHomeDir(),
200
- collectZoxideDirs: zoxide.collectZoxideDirs,
169
+ // Declared here as well as on `serve`, so the parser skips their
170
+ // values when it looks for a subcommand name.
171
+ args: DAEMON_ARGS,
172
+ default: 'serve',
173
+ subCommands: {
174
+ serve: () =>
175
+ defineCommand({
176
+ meta: {
177
+ name: 'serve',
178
+ description: 'Run the atc daemon in the foreground',
179
+ hidden: true,
180
+ },
181
+ args: DAEMON_ARGS,
182
+ async run(ctx) {
183
+ await runDaemon(ctx.args.listen ?? null, ctx.args['token-file'] ?? null);
184
+ },
201
185
  }),
202
- cfg.workspaces.sources,
203
- );
204
-
205
- for (const id of sources.missing) {
206
- console.error(
207
- `atc daemon: config: workspaces.sources holds '${id}', which this daemon cannot offer; the picker leaves it out`,
208
- );
209
- }
210
-
211
- // Cap on how long a fleet restore waits for one revived session to
212
- // report it has booted before moving to the next. Tests pin it to
213
- // keep timing deterministic; unset means the production default.
214
- const capOverride = Number(process.env['ATC_RESTORE_BOOT_TIMEOUT_MS']);
215
-
216
- const restoreBootTimeoutMs =
217
- Number.isFinite(capOverride) && capOverride >= 0 ? capOverride : 15_000;
218
-
219
- // How long a started session may go without a tap before a message
220
- // to it is refused. Tests pin it to 0 to reach the refusal at once.
221
- const graceOverride = Number(process.env['ATC_TAP_GRACE_MS']);
222
-
223
- const claudeAdapter = new claude.ClaudeAdapter(cfg, headless.startClaudeHeadlessRun);
224
- const grokAdapter = new grok.GrokAdapter(cfg);
225
- const codexAdapter = new codex.CodexAdapter(cfg);
226
-
227
- const gatewayAdapters = cfg.gateways.map(
228
- (entry) => new gateway.GatewayAdapter(entry, cfg, headless.startClaudeHeadlessRun),
229
- );
230
-
231
- let handle: Awaited<ReturnType<typeof daemon.startDaemon>>;
232
-
233
- try {
234
- handle = await daemon.startDaemon({
235
- socketPath: config.daemonSocketPath,
236
- reporterSocketPath: config.socketPath,
237
- eventsSocketPath: config.eventsSocketPath,
238
- build: getBuild(),
239
- adapter: claudeAdapter,
240
- adapters: [claudeAdapter, grokAdapter, codexAdapter, ...gatewayAdapters],
241
- dbPath: config.dbFile,
242
- legacyFleetPath: config.legacyFleetFile,
243
- pidPath: config.daemonPidFile,
244
- hooks: cfg.hooks,
245
- targets: built.targets,
246
- defaultTarget: cfg.defaultTarget,
247
- targetErrors,
248
- principals: cfg.principals,
249
- sources: sources.sources,
250
- gitTransports: cfg.workspaces.gitTransports,
251
- restoreBootTimeoutMs,
252
- ...(Number.isFinite(graceOverride) && graceOverride >= 0
253
- ? { tapGraceMs: graceOverride }
254
- : {}),
255
- ...(Number.isFinite(queueBytes) && queueBytes > 0 ? { queueBytes } : {}),
256
- onQuit: () => process.exit(0),
257
- });
258
- } catch (error) {
259
- // A second daemon on the same state directory refuses to start
260
- // and touches nothing; any other startup failure stays a crash.
261
- if (error instanceof Error && Reflect.get(error, 'code') === 'daemon_locked') {
262
- console.error(error.message);
263
- process.exit(1);
264
- }
265
-
266
- throw error;
267
- }
268
-
269
- process.on('SIGTERM', () => {
270
- void (async () => {
271
- await handle.stop();
186
+ id: () =>
187
+ defineCommand({
188
+ meta: {
189
+ name: 'id',
190
+ description: "Print the running daemon's daemonID",
191
+ },
192
+ async run() {
193
+ const id = await import('./run-daemon-id');
272
194
 
273
- process.exit(0);
274
- })();
275
- });
195
+ await id.runDaemonID(getBuild());
196
+ },
197
+ }),
276
198
  },
277
199
  }),
278
200
  events: () =>
@@ -400,4 +322,151 @@ const main = defineCommand({
400
322
  },
401
323
  });
402
324
 
325
+ // Runs the daemon in the foreground until SIGTERM, with a TCP listener when
326
+ // --listen and --token-file are given.
327
+ async function runDaemon(listenArg: string | null, tokenFile: string | null): Promise<void> {
328
+ if ((listenArg === null) !== (tokenFile === null)) {
329
+ console.error('atc daemon: --listen and --token-file go together');
330
+ process.exit(1);
331
+ }
332
+
333
+ const listen = listenArg === null ? null : parseListenAddress(listenArg);
334
+
335
+ if (listen !== null && !listen.ok) {
336
+ console.error(`atc daemon: ${listen.message}`);
337
+ process.exit(1);
338
+ }
339
+
340
+ const daemon = await import('./daemon/daemon');
341
+ const config = await import('./shared/config');
342
+ const claude = await import('./agents/claude-adapter');
343
+ const grok = await import('./agents/grok-adapter');
344
+ const codex = await import('./agents/codex-adapter');
345
+ const gateway = await import('./agents/gateway-adapter');
346
+ const headless = await import('./agents/start-claude-headless-run');
347
+ const targets = await import('./daemon/build-execution-targets');
348
+ const sourceOrder = await import('./sources/build-sources');
349
+ const builtinSources = await import('./sources/collect-builtin-sources');
350
+ const zoxide = await import('./shared/collect-zoxide-dirs');
351
+ const home = await import('./shared/resolve-home-dir');
352
+
353
+ // Test harnesses shrink the outbound queue to force overflow
354
+ // deterministically; unset means the production default.
355
+ const queueBytes = Number(process.env['ATC_QUEUE_BYTES']);
356
+ const cfg = config.loadConfig();
357
+ const built = targets.buildExecutionTargets(cfg.targets);
358
+ const targetErrors = [...cfg.targetErrors, ...built.errors];
359
+
360
+ for (const error of targetErrors) {
361
+ const line =
362
+ error.scope === 'config'
363
+ ? `${error.path} cannot be used (${error.problem}: ${error.detail}); every spawn is refused, local ones included, until it is fixed`
364
+ : error.problem;
365
+
366
+ console.error(`atc daemon: config: ${line}`);
367
+ }
368
+
369
+ for (const problem of [
370
+ ...cfg.principalErrors,
371
+ ...cfg.workspaceErrors,
372
+ ...cfg.authProfileErrors,
373
+ ...cfg.gatewayErrors,
374
+ ]) {
375
+ console.error(`atc daemon: config: ${problem}`);
376
+ }
377
+
378
+ const sources = sourceOrder.buildSources(
379
+ builtinSources.collectBuiltinSources({
380
+ roots: cfg.dirs.roots,
381
+ githubOwner: cfg.workspaces.githubOwner,
382
+ ghBin: 'gh',
383
+ homeDir: home.resolveHomeDir(),
384
+ collectZoxideDirs: zoxide.collectZoxideDirs,
385
+ }),
386
+ cfg.workspaces.sources,
387
+ );
388
+
389
+ for (const id of sources.missing) {
390
+ console.error(
391
+ `atc daemon: config: workspaces.sources holds '${id}', which this daemon cannot offer; the picker leaves it out`,
392
+ );
393
+ }
394
+
395
+ // Cap on how long a fleet restore waits for one revived session to
396
+ // report it has booted before moving to the next. Tests pin it to
397
+ // keep timing deterministic; unset means the production default.
398
+ const capOverride = Number(process.env['ATC_RESTORE_BOOT_TIMEOUT_MS']);
399
+
400
+ const restoreBootTimeoutMs =
401
+ Number.isFinite(capOverride) && capOverride >= 0 ? capOverride : 15_000;
402
+
403
+ // How long a started session may go without a tap before a message
404
+ // to it is refused. Tests pin it to 0 to reach the refusal at once.
405
+ const graceOverride = Number(process.env['ATC_TAP_GRACE_MS']);
406
+
407
+ const claudeAdapter = new claude.ClaudeAdapter(cfg, headless.startClaudeHeadlessRun);
408
+ const grokAdapter = new grok.GrokAdapter(cfg);
409
+ const codexAdapter = new codex.CodexAdapter(cfg);
410
+
411
+ const gatewayAdapters = cfg.gateways.map(
412
+ (entry) => new gateway.GatewayAdapter(entry, cfg, headless.startClaudeHeadlessRun),
413
+ );
414
+
415
+ let handle: Awaited<ReturnType<typeof daemon.startDaemon>>;
416
+
417
+ try {
418
+ handle = await daemon.startDaemon({
419
+ socketPath: config.daemonSocketPath,
420
+ reporterSocketPath: config.socketPath,
421
+ eventsSocketPath: config.eventsSocketPath,
422
+ build: getBuild(),
423
+ adapter: claudeAdapter,
424
+ adapters: [claudeAdapter, grokAdapter, codexAdapter, ...gatewayAdapters],
425
+ dbPath: config.dbFile,
426
+ legacyFleetPath: config.legacyFleetFile,
427
+ pidPath: config.daemonPidFile,
428
+ hooks: cfg.hooks,
429
+ targets: built.targets,
430
+ defaultTarget: cfg.defaultTarget,
431
+ targetErrors,
432
+ principals: cfg.principals,
433
+ sources: sources.sources,
434
+ gitTransports: cfg.workspaces.gitTransports,
435
+ ...(listen === null || tokenFile === null
436
+ ? {}
437
+ : { listen: { host: listen.host, port: listen.port, tokenFile } }),
438
+ restoreBootTimeoutMs,
439
+ ...(Number.isFinite(graceOverride) && graceOverride >= 0
440
+ ? { tapGraceMs: graceOverride }
441
+ : {}),
442
+ ...(Number.isFinite(queueBytes) && queueBytes > 0 ? { queueBytes } : {}),
443
+ onQuit: () => process.exit(0),
444
+ });
445
+ } catch (error) {
446
+ // A second daemon on the same state directory, or a listener whose
447
+ // address or token file is refused or whose bind fails, stops the start
448
+ // with nothing left held; any other startup failure stays a crash.
449
+ const code: unknown = error instanceof Error ? Reflect.get(error, 'code') : null;
450
+
451
+ if (error instanceof Error && (code === 'daemon_locked' || code === 'listen_refused')) {
452
+ console.error(error.message);
453
+ process.exit(1);
454
+ }
455
+
456
+ throw error;
457
+ }
458
+
459
+ process.on('SIGHUP', () => {
460
+ handle.refreshTokens();
461
+ });
462
+
463
+ process.on('SIGTERM', () => {
464
+ void (async () => {
465
+ await handle.stop();
466
+
467
+ process.exit(0);
468
+ })();
469
+ });
470
+ }
471
+
403
472
  await runMain(main, { rawArgs: normalizeCLIArgs(process.argv.slice(2)) });
@@ -1,3 +1,4 @@
1
+ import type { SocketHandler } from 'bun';
1
2
  import type { DaemonChannel } from '../protocol/daemon-channel';
2
3
  import { DaemonError } from '../protocol/daemon-error';
3
4
  import { LineDecoder } from '../protocol/line-decoder';
@@ -34,25 +35,30 @@ export class DaemonClient implements DaemonChannel {
34
35
  // once instead of waiting on a response that can never arrive.
35
36
  private closedReason: string | null = null;
36
37
 
37
- static async open(socketPath: string): Promise<DaemonClient> {
38
+ // Connects to the daemon at a unix socket path, or at a TCP address.
39
+ static async open(
40
+ address: string | { readonly hostname: string; readonly port: number },
41
+ ): Promise<DaemonClient> {
38
42
  const client = new DaemonClient();
39
43
 
40
- const socket = await Bun.connect({
41
- unix: socketPath,
42
- socket: {
43
- data(_s, buf) {
44
- client.applyChunk(buf);
45
- },
46
- drain() {
47
- client.queue?.drain();
48
- },
49
- close() {
50
- client.drainPending('connection closed');
51
- client.onClose();
52
- },
53
- error() {},
44
+ const handlers: SocketHandler = {
45
+ data(_s, buf) {
46
+ client.applyChunk(buf);
54
47
  },
55
- });
48
+ drain() {
49
+ client.queue?.drain();
50
+ },
51
+ close() {
52
+ client.drainPending('connection closed');
53
+ client.onClose();
54
+ },
55
+ error() {},
56
+ };
57
+
58
+ const socket =
59
+ typeof address === 'string'
60
+ ? await Bun.connect({ unix: address, socket: handlers })
61
+ : await Bun.connect({ hostname: address.hostname, port: address.port, socket: handlers });
56
62
 
57
63
  client.socket = socket;
58
64
 
@@ -61,8 +67,13 @@ export class DaemonClient implements DaemonChannel {
61
67
  return client;
62
68
  }
63
69
 
64
- sendHello(build: string): Promise<Readonly<Record<string, unknown>>> {
65
- return this.sendRequest('daemon.hello', { client: build, auth: { scheme: 'none' } });
70
+ // A handshake with a token presents it as a bearer token, which a TCP
71
+ // listener requires.
72
+ sendHello(build: string, token?: string): Promise<Readonly<Record<string, unknown>>> {
73
+ return this.sendRequest('daemon.hello', {
74
+ client: build,
75
+ auth: token === undefined ? { scheme: 'none' } : { scheme: 'bearer', token },
76
+ });
66
77
  }
67
78
 
68
79
  // A request with a principal acts as that principal, within what the
@@ -3,11 +3,12 @@ import { isRecord } from '../shared/report';
3
3
 
4
4
  /**
5
5
  * The SHA-256 of a request's params as canonical JSON: object keys sorted
6
- * at every depth, with the idempotency key itself left out. Two requests
7
- * that differ only in key order or in their idempotency key hash the same.
6
+ * at every depth, with the idempotency key and the replay-only flag left
7
+ * out. Two requests that differ only in key order, in their idempotency
8
+ * key, or in whether they only replay hash the same.
8
9
  */
9
10
  export function buildPayloadHash(params: Readonly<Record<string, unknown>>): string {
10
- const { idempotencyKey: _ignored, ...payload } = params;
11
+ const { idempotencyKey: _ignored, replayOnly: _replayOnly, ...payload } = params;
11
12
 
12
13
  return createHash('sha256')
13
14
  .update(JSON.stringify(sortKeys(payload)))