@zgeoff/atc 0.1.12 → 1.0.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 (75) hide show
  1. package/README.md +50 -12
  2. package/package.json +7 -2
  3. package/src/{agent-adapter.ts → agents/agent-adapter.ts} +31 -14
  4. package/src/{build-cli-command.ts → agents/build-cli-command.ts} +1 -1
  5. package/src/agents/build-hook-settings.ts +59 -0
  6. package/src/{claude-adapter.ts → agents/claude-adapter.ts} +41 -80
  7. package/src/{codex-adapter.ts → agents/codex-adapter.ts} +41 -30
  8. package/src/agents/gateway-adapter.ts +106 -0
  9. package/src/{grok-adapter.ts → agents/grok-adapter.ts} +83 -46
  10. package/src/agents/resolve-agent-home.ts +13 -0
  11. package/src/agents/truncate-detail.ts +7 -0
  12. package/src/agents/write-hook-settings.ts +37 -0
  13. package/src/cli.ts +25 -14
  14. package/src/{boot-daemon.ts → client/boot-daemon.ts} +8 -7
  15. package/src/client/build-client-machine.ts +119 -0
  16. package/src/client/collect-agent-picks.ts +31 -0
  17. package/src/{daemon-client.ts → client/daemon-client.ts} +4 -4
  18. package/src/{dirs.ts → client/dirs.ts} +8 -0
  19. package/src/client/format-overlay-agent-mark.ts +15 -0
  20. package/src/{index.ts → client/index.ts} +181 -550
  21. package/src/client/keys.ts +111 -0
  22. package/src/client/parse-daemon-event.ts +114 -0
  23. package/src/{pick-tab-target.ts → client/pick-tab-target.ts} +2 -2
  24. package/src/client/plan-vacated-rows.ts +26 -0
  25. package/src/client/spawn-picker.ts +305 -0
  26. package/src/client/to-mirror-session.ts +65 -0
  27. package/src/{ui.ts → client/ui.ts} +27 -8
  28. package/src/{attach-registry.ts → daemon/attach-registry.ts} +12 -10
  29. package/src/daemon/build-session-event.ts +43 -0
  30. package/src/{daemon-connection.ts → daemon/daemon-connection.ts} +194 -108
  31. package/src/{daemon.ts → daemon/daemon.ts} +148 -313
  32. package/src/{hooks.ts → daemon/hooks.ts} +6 -4
  33. package/src/daemon/mint-session-id.ts +12 -0
  34. package/src/{permission-registry.ts → daemon/permission-registry.ts} +5 -3
  35. package/src/daemon/restore-fleet.ts +141 -0
  36. package/src/daemon/run-eject-handoff.ts +33 -0
  37. package/src/{screen-model.ts → daemon/screen-model.ts} +1 -1
  38. package/src/daemon/session-runtime.ts +64 -0
  39. package/src/{sessions.ts → daemon/sessions.ts} +77 -133
  40. package/src/{start-headless-run.ts → daemon/start-headless-run.ts} +9 -3
  41. package/src/daemon/start-headless-turn.ts +57 -0
  42. package/src/hook-report.ts +2 -2
  43. package/src/mcp-server.ts +33 -44
  44. package/src/protocol/parse-request-params.ts +31 -0
  45. package/src/{protocol.ts → protocol/protocol.ts} +1 -1
  46. package/src/protocol/request-param-schemas.ts +109 -0
  47. package/src/shared/agent-session-id.ts +8 -0
  48. package/src/shared/build-optional-boolean.ts +10 -0
  49. package/src/shared/build-optional-string-array.ts +14 -0
  50. package/src/shared/build-optional-string.ts +10 -0
  51. package/src/shared/collect-gateways.ts +113 -0
  52. package/src/{config.ts → shared/config.ts} +47 -31
  53. package/src/{get-build.ts → shared/get-build.ts} +21 -6
  54. package/src/shared/get-record.ts +20 -0
  55. package/src/shared/session-id.ts +8 -0
  56. package/src/shared/to-agent-session-id.ts +12 -0
  57. package/src/shared/to-session-id.ts +11 -0
  58. package/src/shared/to-shell-arg.ts +7 -0
  59. package/src/statusline.ts +2 -2
  60. package/src/store/bun-sqlite-driver.ts +96 -0
  61. package/src/store/fleet-entry.ts +72 -0
  62. package/src/store/run-migrations.ts +242 -0
  63. package/src/store/state-store.ts +226 -0
  64. package/src/format-overlay-agent-mark.ts +0 -17
  65. package/src/state-store.ts +0 -236
  66. /package/src/{normalize-hook-event.ts → agents/normalize-hook-event.ts} +0 -0
  67. /package/src/{print-codex-hook-file.ts → agents/print-codex-hook-file.ts} +0 -0
  68. /package/src/{print-grok-hook-file.ts → agents/print-grok-hook-file.ts} +0 -0
  69. /package/src/{build-leader-chords.ts → client/build-leader-chords.ts} +0 -0
  70. /package/src/{daemon-error.ts → protocol/daemon-error.ts} +0 -0
  71. /package/src/{outbound-queue.ts → protocol/outbound-queue.ts} +0 -0
  72. /package/src/{collect-clean-env.ts → shared/collect-clean-env.ts} +0 -0
  73. /package/src/{report.ts → shared/report.ts} +0 -0
  74. /package/src/{reset-input-modes.ts → shared/reset-input-modes.ts} +0 -0
  75. /package/src/{resolve-repo-root.ts → shared/resolve-repo-root.ts} +0 -0
@@ -0,0 +1,106 @@
1
+ import type { HookEvent } from '../daemon/hooks';
2
+ import type { AgentSessionID } from '../shared/agent-session-id';
3
+ import type { GatewayConfig } from '../shared/collect-gateways';
4
+ import type { Config } from '../shared/config';
5
+ import { toShellArg } from '../shared/to-shell-arg';
6
+ import type {
7
+ AdapterEvent,
8
+ AgentAdapter,
9
+ AgentID,
10
+ HeadlessRunner,
11
+ NameUpdate,
12
+ ResumeCheck,
13
+ SpawnOptions,
14
+ SpawnPlan,
15
+ } from './agent-adapter';
16
+ import { ClaudeAdapter } from './claude-adapter';
17
+ import { writeHookSettings } from './write-hook-settings';
18
+
19
+ /**
20
+ * The Claude CLI pointed at a Claude-compatible backend other than the
21
+ * default one. Hooks, transcripts, and resume semantics are the CLI's, so
22
+ * they come straight from the Claude adapter; only the spawn plan and the
23
+ * resume command differ, because both have to name the settings file that
24
+ * carries the backend.
25
+ */
26
+ export class GatewayAdapter implements AgentAdapter {
27
+ readonly id: AgentID;
28
+
29
+ // A headless turn carries the same settings file the terminal spawn does,
30
+ // so it reaches this backend rather than the default one.
31
+ readonly headlessRunner: HeadlessRunner | null;
32
+
33
+ // The CLI's hooks are authoritative; no screen heuristics needed.
34
+ readonly screenDetector = null;
35
+
36
+ private readonly gateway: GatewayConfig;
37
+
38
+ private readonly claude: ClaudeAdapter;
39
+
40
+ // Written on first spawn so constructing the adapter touches no state.
41
+ private settingsFile: string | undefined;
42
+
43
+ constructor(
44
+ gateway: GatewayConfig,
45
+ config: Config,
46
+ headlessRunner: HeadlessRunner | null = null,
47
+ ) {
48
+ this.gateway = gateway;
49
+ this.id = gateway.id;
50
+
51
+ this.claude = new ClaudeAdapter(config);
52
+
53
+ this.headlessRunner =
54
+ headlessRunner === null
55
+ ? null
56
+ : (opts, hooks) => headlessRunner({ ...opts, settings: this.writeSettings() }, hooks);
57
+ }
58
+
59
+ planSpawn(opts: SpawnOptions): SpawnPlan {
60
+ return {
61
+ bin: this.gateway.bin,
62
+ args: [
63
+ ...this.gateway.args,
64
+ '--settings',
65
+ this.writeSettings(),
66
+ ...(opts.resume === true ? ['--resume'] : []),
67
+ ...(typeof opts.resume === 'string' ? ['--resume', opts.resume] : []),
68
+ ...(opts.prompt === '' ? [] : [opts.prompt]),
69
+ ],
70
+ };
71
+ }
72
+
73
+ normalizeHook(e: HookEvent): AdapterEvent {
74
+ return this.claude.normalizeHook(e);
75
+ }
76
+
77
+ loadName(source: string, namedBy: 'user' | 'auto' | 'agent'): Promise<NameUpdate | null> {
78
+ return this.claude.loadName(source, namedBy);
79
+ }
80
+
81
+ canResume(session: ResumeCheck): boolean {
82
+ return this.claude.canResume(session);
83
+ }
84
+
85
+ // Shell command that re-opens this session outside atc. It names the
86
+ // generated settings file, because without it the CLI would resume the
87
+ // session against the default backend.
88
+ buildResumeCommand(cwd: string, agentSessionID: AgentSessionID | undefined): string | null {
89
+ const settings = toShellArg(this.writeSettings());
90
+ const resume = agentSessionID === undefined ? '' : ` ${agentSessionID}`;
91
+
92
+ return `cd ${toShellArg(cwd)} && ${this.gateway.bin} --settings ${settings} --resume${resume}`;
93
+ }
94
+
95
+ private writeSettings(): string {
96
+ this.settingsFile ??= writeHookSettings({
97
+ id: this.id,
98
+ env: { ANTHROPIC_BASE_URL: this.gateway.baseURL, ...this.gateway.env },
99
+ ...(this.gateway.apiKeyHelper === undefined
100
+ ? {}
101
+ : { apiKeyHelper: this.gateway.apiKeyHelper }),
102
+ });
103
+
104
+ return this.settingsFile;
105
+ }
106
+ }
@@ -1,6 +1,14 @@
1
1
  import { existsSync, readFileSync, readdirSync } from 'node:fs';
2
- import { homedir } from 'node:os';
3
2
  import { join } from 'node:path';
3
+ import { z } from 'zod';
4
+ import type { HookEvent } from '../daemon/hooks';
5
+ import type { AgentSessionID } from '../shared/agent-session-id';
6
+ import { buildOptionalString } from '../shared/build-optional-string';
7
+ import type { Config } from '../shared/config';
8
+ import { isRecord } from '../shared/report';
9
+ import type { SessionID } from '../shared/session-id';
10
+ import { toAgentSessionID } from '../shared/to-agent-session-id';
11
+ import { toShellArg } from '../shared/to-shell-arg';
4
12
  import type {
5
13
  AdapterEvent,
6
14
  AgentAdapter,
@@ -9,22 +17,42 @@ import type {
9
17
  SpawnOptions,
10
18
  SpawnPlan,
11
19
  } from './agent-adapter';
12
- import type { Config } from './config';
13
- import type { HookEvent } from './hooks';
14
20
  import { normalizeHookEventName } from './normalize-hook-event';
15
- import { isRecord } from './report';
21
+ import { resolveAgentHome } from './resolve-agent-home';
22
+ import { truncateDetail } from './truncate-detail';
16
23
 
17
24
  interface GrokSessionHookState {
18
25
  latestPromptID?: string;
19
26
  lastKind: AdapterEvent['kind'];
20
27
  }
21
28
 
29
+ // Bounds per-session hook state so a Grok session killed or crashed without
30
+ // a SessionEnd hook cannot grow it for the daemon's whole lifetime.
31
+ const MAX_HOOK_STATE_ENTRIES = 256;
32
+
33
+ // Grok's hook payload keys, camelCase. An absent or wrong-typed field parses
34
+ // to undefined rather than failing the payload, so a broken reporter never
35
+ // breaks the session it reports on.
36
+ const GROK_HOOK_PAYLOAD_SCHEMA = z.object({
37
+ sessionId: buildOptionalString(),
38
+ cwd: buildOptionalString(),
39
+ promptId: buildOptionalString(),
40
+ subagentType: buildOptionalString(),
41
+ prompt: buildOptionalString(),
42
+ notificationType: buildOptionalString(),
43
+ message: buildOptionalString(),
44
+ reason: buildOptionalString(),
45
+ lastAssistantMessage: buildOptionalString(),
46
+ });
47
+
48
+ type GrokHookPayload = z.infer<typeof GROK_HOOK_PAYLOAD_SCHEMA>;
49
+
22
50
  /**
23
51
  * The Grok Build adapter: spawn arguments, hook payload mapping,
24
52
  * resume semantics, and summary.json name-pulling.
25
53
  */
26
54
  export class GrokAdapter implements AgentAdapter {
27
- readonly kind = 'grok';
55
+ readonly id = 'grok';
28
56
 
29
57
  // Grok has no headless handoff.
30
58
  readonly headlessRunner = null;
@@ -34,7 +62,7 @@ export class GrokAdapter implements AgentAdapter {
34
62
 
35
63
  private readonly config: Config;
36
64
 
37
- private readonly hookState = new Map<string, GrokSessionHookState>();
65
+ private readonly hookState = new Map<SessionID, GrokSessionHookState>();
38
66
 
39
67
  constructor(config: Config) {
40
68
  this.config = config;
@@ -53,32 +81,34 @@ export class GrokAdapter implements AgentAdapter {
53
81
  }
54
82
 
55
83
  normalizeHook(e: HookEvent): AdapterEvent {
56
- const sessionID = e.payload['sessionId'];
57
- const cwd = e.payload['cwd'];
58
- const promptID = e.payload['promptId'];
59
- const subagentType = e.payload['subagentType'];
84
+ const parsed = GROK_HOOK_PAYLOAD_SCHEMA.safeParse(e.payload);
85
+ const payload: GrokHookPayload = parsed.success ? parsed.data : {};
86
+
87
+ const agentSessionID =
88
+ payload.sessionId === undefined ? undefined : toAgentSessionID(payload.sessionId);
60
89
 
61
90
  const base: AdapterEvent = {
62
91
  kind: 'heartbeat',
63
- ...(typeof sessionID === 'string' ? { agentSessionID: sessionID } : {}),
92
+ ...(agentSessionID === undefined ? {} : { agentSessionID }),
64
93
  };
65
94
 
66
95
  const named: AdapterEvent = {
67
96
  ...base,
68
- ...(typeof sessionID === 'string' && typeof cwd === 'string'
69
- ? { nameSource: buildGrokNameSource(sessionID, cwd) }
97
+ ...(agentSessionID !== undefined && payload.cwd !== undefined
98
+ ? { nameSource: buildGrokNameSource(agentSessionID, payload.cwd) }
70
99
  : {}),
71
100
  };
72
101
 
73
- if (typeof subagentType === 'string' && subagentType !== '') {
102
+ if (payload.subagentType !== undefined && payload.subagentType !== '') {
74
103
  return this.emitHook(e.atcId, { ...base, kind: 'heartbeat' });
75
104
  }
76
105
 
77
106
  const event = normalizeHookEventName(e.event);
78
107
  const prior = this.hookState.get(e.atcId);
108
+ const promptID = payload.promptId;
79
109
 
80
110
  const stale =
81
- typeof promptID === 'string' &&
111
+ promptID !== undefined &&
82
112
  prior?.latestPromptID !== undefined &&
83
113
  promptID !== prior.latestPromptID;
84
114
 
@@ -87,9 +117,7 @@ export class GrokAdapter implements AgentAdapter {
87
117
  return this.emitHook(e.atcId, { ...named, kind: 'started' });
88
118
  }
89
119
  case 'UserPromptSubmit': {
90
- const prompt = e.payload['prompt'];
91
- const preview = typeof prompt === 'string' ? prompt.slice(0, 80) : '';
92
- const submittedID = typeof promptID === 'string' ? promptID : undefined;
120
+ const preview = payload.prompt === undefined ? '' : payload.prompt.slice(0, 80);
93
121
 
94
122
  return this.emitHook(
95
123
  e.atcId,
@@ -98,18 +126,18 @@ export class GrokAdapter implements AgentAdapter {
98
126
  kind: 'prompt-submitted',
99
127
  ...(preview === '' ? {} : { message: preview, detail: truncateDetail(preview) }),
100
128
  },
101
- submittedID,
129
+ promptID,
102
130
  );
103
131
  }
104
132
  case 'Notification': {
105
- const notificationType = e.payload['notificationType'];
106
- const message = e.payload['message'];
133
+ const notificationType = payload.notificationType;
134
+ const message = payload.message;
107
135
 
108
136
  if (notificationType === 'permission_prompt') {
109
137
  return this.emitHook(e.atcId, {
110
138
  ...base,
111
139
  kind: 'needs-input',
112
- ...(typeof message === 'string' && message !== ''
140
+ ...(message !== undefined && message !== ''
113
141
  ? { message, detail: truncateDetail(message) }
114
142
  : {}),
115
143
  });
@@ -124,11 +152,11 @@ export class GrokAdapter implements AgentAdapter {
124
152
  return this.emitHook(e.atcId, base);
125
153
  }
126
154
  case 'Stop': {
127
- if (e.payload['reason'] !== 'end_turn' || stale) {
155
+ if (payload.reason !== 'end_turn' || stale) {
128
156
  return this.emitHook(e.atcId, base);
129
157
  }
130
158
 
131
- return this.emitHook(e.atcId, buildTurnDoneEvent(named, e.payload));
159
+ return this.emitHook(e.atcId, buildTurnDoneEvent(named, payload));
132
160
  }
133
161
  case 'StopFailure':
134
162
  case 'StopCancelled': {
@@ -136,7 +164,7 @@ export class GrokAdapter implements AgentAdapter {
136
164
  return this.emitHook(e.atcId, base);
137
165
  }
138
166
 
139
- return this.emitHook(e.atcId, buildTurnDoneEvent(named, e.payload));
167
+ return this.emitHook(e.atcId, buildTurnDoneEvent(named, payload));
140
168
  }
141
169
  case 'SessionEnd': {
142
170
  this.hookState.delete(e.atcId);
@@ -181,14 +209,17 @@ export class GrokAdapter implements AgentAdapter {
181
209
  }
182
210
 
183
211
  // Shell command that re-opens this session outside atc (or anywhere).
184
- buildResumeCommand(cwd: string, agentSessionID: string | undefined): string | null {
212
+ buildResumeCommand(cwd: string, agentSessionID: AgentSessionID | undefined): string | null {
185
213
  const resume = agentSessionID === undefined ? 'grok' : `grok --resume ${agentSessionID}`;
186
- const quoted = cwd.replaceAll("'", String.raw`'\''`);
187
214
 
188
- return `cd '${quoted}' && ${resume}`;
215
+ return `cd ${toShellArg(cwd)} && ${resume}`;
189
216
  }
190
217
 
191
- private emitHook(atcID: string, event: Readonly<AdapterEvent>, promptID?: string): AdapterEvent {
218
+ private emitHook(
219
+ atcID: SessionID,
220
+ event: Readonly<AdapterEvent>,
221
+ promptID?: string,
222
+ ): AdapterEvent {
192
223
  const prior = this.hookState.get(atcID);
193
224
  const latestPromptID = promptID ?? prior?.latestPromptID;
194
225
 
@@ -199,6 +230,10 @@ export class GrokAdapter implements AgentAdapter {
199
230
  ? event.kind
200
231
  : (prior?.lastKind ?? event.kind);
201
232
 
233
+ if (prior === undefined && this.hookState.size >= MAX_HOOK_STATE_ENTRIES) {
234
+ this.removeOldestHookState();
235
+ }
236
+
202
237
  this.hookState.set(atcID, {
203
238
  lastKind: tracked,
204
239
  ...(latestPromptID === undefined ? {} : { latestPromptID }),
@@ -206,28 +241,34 @@ export class GrokAdapter implements AgentAdapter {
206
241
 
207
242
  return event;
208
243
  }
209
- }
210
244
 
211
- function resolveGrokHome(): string {
212
- const home = process.env['GROK_HOME'];
245
+ // A Map preserves insertion order, so the first key is the oldest entry.
246
+ private removeOldestHookState(): void {
247
+ const oldest = this.hookState.keys().next().value;
213
248
 
214
- return home !== undefined && home !== '' ? home : join(homedir(), '.grok');
249
+ if (oldest !== undefined) {
250
+ this.hookState.delete(oldest);
251
+ }
252
+ }
215
253
  }
216
254
 
217
- function buildGrokNameSource(sessionID: string, cwd: string): string {
218
- return join(resolveGrokHome(), 'sessions', encodeURIComponent(cwd), sessionID, 'summary.json');
255
+ function buildGrokNameSource(sessionID: AgentSessionID, cwd: string): string {
256
+ return join(
257
+ resolveAgentHome('GROK_HOME', '.grok'),
258
+ 'sessions',
259
+ encodeURIComponent(cwd),
260
+ sessionID,
261
+ 'summary.json',
262
+ );
219
263
  }
220
264
 
221
- function buildTurnDoneEvent(
222
- named: Readonly<AdapterEvent>,
223
- payload: Readonly<Record<string, unknown>>,
224
- ): AdapterEvent {
225
- const lastMessage = payload['lastAssistantMessage'];
265
+ function buildTurnDoneEvent(named: Readonly<AdapterEvent>, payload: GrokHookPayload): AdapterEvent {
266
+ const lastMessage = payload.lastAssistantMessage;
226
267
 
227
268
  return {
228
269
  ...named,
229
270
  kind: 'turn-done',
230
- ...(typeof lastMessage === 'string' && lastMessage !== ''
271
+ ...(lastMessage !== undefined && lastMessage !== ''
231
272
  ? { detail: truncateDetail(lastMessage) }
232
273
  : {}),
233
274
  };
@@ -254,7 +295,7 @@ function findGrokSummary(source: string): string | null {
254
295
  return null;
255
296
  }
256
297
 
257
- const root = join(resolveGrokHome(), 'sessions');
298
+ const root = join(resolveAgentHome('GROK_HOME', '.grok'), 'sessions');
258
299
 
259
300
  try {
260
301
  for (const group of readdirSync(root)) {
@@ -268,7 +309,3 @@ function findGrokSummary(source: string): string | null {
268
309
 
269
310
  return null;
270
311
  }
271
-
272
- function truncateDetail(text: string): string {
273
- return text.length <= 600 ? text : `${text.slice(0, 599)}…`;
274
- }
@@ -0,0 +1,13 @@
1
+ import { homedir } from 'node:os';
2
+ import { join } from 'node:path';
3
+
4
+ /**
5
+ * An agent CLI's home directory: the named environment variable when it is
6
+ * set and non-empty, otherwise the given default directory under the user's
7
+ * home.
8
+ */
9
+ export function resolveAgentHome(envVar: string, defaultDirName: string): string {
10
+ const home = process.env[envVar];
11
+
12
+ return home !== undefined && home !== '' ? home : join(homedir(), defaultDirName);
13
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Caps a detail string at 600 characters, ellipsizing anything longer so a
3
+ * single hook payload cannot blow up the briefing text.
4
+ */
5
+ export function truncateDetail(text: string): string {
6
+ return text.length <= 600 ? text : `${text.slice(0, 599)}…`;
7
+ }
@@ -0,0 +1,37 @@
1
+ import { readFileSync, writeFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { stateDir } from '../shared/config';
5
+ import { isRecord } from '../shared/report';
6
+ import { buildHookSettings } from './build-hook-settings';
7
+ import type { HookSettingsProfile } from './build-hook-settings';
8
+
9
+ /**
10
+ * Writes the settings file passed to a wrangled session as
11
+ * `claude --settings`, one per agent id, and returns its path.
12
+ */
13
+ export function writeHookSettings(profile: HookSettingsProfile): string {
14
+ const file = join(stateDir, `hook-settings-${profile.id}.json`);
15
+ const settings = buildHookSettings(profile, readStatuslinePadding());
16
+
17
+ writeFileSync(file, JSON.stringify(settings, null, 2));
18
+
19
+ return file;
20
+ }
21
+
22
+ /**
23
+ * The padding on the user's own statusline, so the chained one lines up with
24
+ * it. An unreadable or unconfigured setting is no padding.
25
+ */
26
+ function readStatuslinePadding(): number {
27
+ try {
28
+ const raw = readFileSync(join(homedir(), '.claude', 'settings.json'), 'utf8');
29
+ const user: unknown = JSON.parse(raw);
30
+ const statusLine = isRecord(user) ? user['statusLine'] : undefined;
31
+ const padding = isRecord(statusLine) ? statusLine['padding'] : undefined;
32
+
33
+ return typeof padding === 'number' ? padding : 0;
34
+ } catch {
35
+ return 0;
36
+ }
37
+ }
package/src/cli.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  // `statusline` are the commands injected into wrangled sessions.
3
3
  import { defineCommand, runMain } from 'citty';
4
4
  import pkg from '../package.json';
5
- import { getBuild } from './get-build';
5
+ import { getBuild } from './shared/get-build';
6
6
 
7
7
  const main = defineCommand({
8
8
  meta: {
@@ -20,7 +20,7 @@ const main = defineCommand({
20
20
  hidden: true,
21
21
  },
22
22
  async run() {
23
- await import('./index');
23
+ await import('./client/index');
24
24
  },
25
25
  }),
26
26
  mcp: () =>
@@ -42,12 +42,13 @@ const main = defineCommand({
42
42
  description: 'Run the atc daemon in the foreground',
43
43
  },
44
44
  async run() {
45
- const daemon = await import('./daemon');
46
- const config = await import('./config');
47
- const claude = await import('./claude-adapter');
48
- const grok = await import('./grok-adapter');
49
- const codex = await import('./codex-adapter');
50
- const headless = await import('./start-headless-run');
45
+ const daemon = await import('./daemon/daemon');
46
+ const config = await import('./shared/config');
47
+ const claude = await import('./agents/claude-adapter');
48
+ const grok = await import('./agents/grok-adapter');
49
+ const codex = await import('./agents/codex-adapter');
50
+ const gateway = await import('./agents/gateway-adapter');
51
+ const headless = await import('./daemon/start-headless-run');
51
52
 
52
53
  // Test harnesses shrink the outbound queue to force overflow
53
54
  // deterministically; unset means the production default.
@@ -69,12 +70,19 @@ const main = defineCommand({
69
70
  const grokAdapter = new grok.GrokAdapter(cfg);
70
71
  const codexAdapter = new codex.CodexAdapter(cfg);
71
72
 
72
- const handle = daemon.startDaemon({
73
+ const gatewayAdapters = cfg.gateways.map(
74
+ (entry) =>
75
+ new gateway.GatewayAdapter(entry, cfg, (runOpts, hooks) =>
76
+ headless.startHeadlessRun(runOpts, hooks),
77
+ ),
78
+ );
79
+
80
+ const handle = await daemon.startDaemon({
73
81
  socketPath: config.daemonSocketPath,
74
82
  reporterSocketPath: config.socketPath,
75
83
  build: getBuild(),
76
84
  adapter: claudeAdapter,
77
- adapters: { claude: claudeAdapter, grok: grokAdapter, codex: codexAdapter },
85
+ adapters: [claudeAdapter, grokAdapter, codexAdapter, ...gatewayAdapters],
78
86
  dbPath: config.dbFile,
79
87
  legacyFleetPath: config.legacyFleetFile,
80
88
  pidPath: config.daemonPidFile,
@@ -84,8 +92,11 @@ const main = defineCommand({
84
92
  });
85
93
 
86
94
  process.on('SIGTERM', () => {
87
- handle.stop();
88
- process.exit(0);
95
+ void (async () => {
96
+ await handle.stop();
97
+
98
+ process.exit(0);
99
+ })();
89
100
  });
90
101
  },
91
102
  }),
@@ -97,7 +108,7 @@ const main = defineCommand({
97
108
  'Print the Codex hook entries to merge into $CODEX_HOME/hooks.json (trust them once in the Codex TUI)',
98
109
  },
99
110
  async run() {
100
- const hook = await import('./print-codex-hook-file');
111
+ const hook = await import('./agents/print-codex-hook-file');
101
112
 
102
113
  hook.printCodexHookFile();
103
114
  },
@@ -109,7 +120,7 @@ const main = defineCommand({
109
120
  description: 'Print the Grok hook file to install at $GROK_HOME/hooks/atc-reporter.json',
110
121
  },
111
122
  async run() {
112
- const hook = await import('./print-grok-hook-file');
123
+ const hook = await import('./agents/print-grok-hook-file');
113
124
 
114
125
  hook.printGrokHookFile();
115
126
  },
@@ -1,16 +1,17 @@
1
1
  import { spawn as spawnChild } from 'node:child_process';
2
2
  import { readFileSync } from 'node:fs';
3
3
  import { basename, join } from 'node:path';
4
- import type { AgentKind } from './agent-adapter';
5
- import { daemonPidFile, daemonSocketPath } from './config';
4
+ import { toAgentID } from '../agents/agent-adapter';
5
+ import type { AgentID } from '../agents/agent-adapter';
6
+ import { daemonPidFile, daemonSocketPath } from '../shared/config';
7
+ import { getBuild } from '../shared/get-build';
8
+ import { isRecord } from '../shared/report';
6
9
  import { DaemonClient } from './daemon-client';
7
- import { getBuild } from './get-build';
8
- import { isRecord } from './report';
9
10
 
10
11
  export interface DaemonBoot {
11
12
  readonly client: DaemonClient;
12
13
  readonly stale: boolean;
13
- readonly lastUsedAgent: AgentKind;
14
+ readonly lastUsedAgent: AgentID;
14
15
  }
15
16
 
16
17
  /**
@@ -35,7 +36,7 @@ export async function bootDaemonClient(): Promise<DaemonBoot> {
35
36
  return {
36
37
  client,
37
38
  stale: hello['daemon'] !== build,
38
- lastUsedAgent: hello['lastUsedAgent'] === 'grok' ? 'grok' : 'claude',
39
+ lastUsedAgent: toAgentID(hello['lastUsedAgent']),
39
40
  };
40
41
  } catch (error) {
41
42
  client.stop();
@@ -120,7 +121,7 @@ async function stopStaleDaemon(): Promise<void> {
120
121
  function spawnDaemonDetached() {
121
122
  const exec = process.execPath;
122
123
  const isBun = basename(exec) === 'bun' || basename(exec) === 'bun.exe';
123
- const args = isBun ? [join(import.meta.dir, 'cli.ts'), 'daemon'] : ['daemon'];
124
+ const args = isBun ? [join(import.meta.dir, '..', 'cli.ts'), 'daemon'] : ['daemon'];
124
125
 
125
126
  spawnChild(exec, args, { detached: true, stdio: 'ignore' }).unref();
126
127
  }
@@ -0,0 +1,119 @@
1
+ import { setup } from 'xstate';
2
+
3
+ type ClientMachineEvent =
4
+ | { readonly type: 'ATTACH'; readonly sessionID: string }
5
+ | { readonly type: 'HOME' }
6
+ | { readonly type: 'OVERLAY' }
7
+ | { readonly type: 'HELP' }
8
+ | { readonly type: 'SPAWN'; readonly resume: boolean }
9
+ | { readonly type: 'EJECT'; readonly sessionID: string };
10
+
11
+ // What the machine borrows from the client that owns the screen, the fleet
12
+ // mirror, and the daemon connection. Every prop is a side effect the machine
13
+ // triggers but never performs itself.
14
+ export interface ClientMachineDeps {
15
+ readonly openHome: () => void;
16
+ readonly openAttached: (sessionID: string) => void;
17
+ readonly openOverlay: () => void;
18
+ readonly openHelp: () => void;
19
+ readonly openPicker: (resume: boolean) => void;
20
+ readonly openEject: (sessionID: string) => void;
21
+ }
22
+
23
+ /**
24
+ * The client's mode as a statechart: which mode each event moves to, and
25
+ * what runs on entry. `ATTACH` and `OVERLAY` reach several of the modes
26
+ * below because several keys and outcomes lead to the same place —
27
+ * attaching a session, or falling back to the overlay — and each landing
28
+ * runs the same entry work regardless of where it was sent from.
29
+ */
30
+ export function buildClientMachine(deps: ClientMachineDeps) {
31
+ return setup({
32
+ types: {
33
+ // oxlint-disable-next-line no-unsafe-type-assertion -- xstate's typed-setup contract: this value is never read, it only carries the event union past the type layer
34
+ events: {} as ClientMachineEvent,
35
+ },
36
+ actions: {
37
+ openHome: () => {
38
+ deps.openHome();
39
+ },
40
+ openAttached: (args) => {
41
+ if (args.event.type === 'ATTACH') {
42
+ deps.openAttached(args.event.sessionID);
43
+ }
44
+ },
45
+ openOverlay: () => {
46
+ deps.openOverlay();
47
+ },
48
+ openHelp: () => {
49
+ deps.openHelp();
50
+ },
51
+ },
52
+ }).createMachine({
53
+ id: 'client',
54
+ initial: 'home',
55
+ states: {
56
+ home: {
57
+ entry: 'openHome',
58
+ on: {
59
+ ATTACH: 'attached',
60
+ HOME: { target: 'home', reenter: true },
61
+ OVERLAY: 'overlay',
62
+ SPAWN: {
63
+ target: 'picker',
64
+ actions: (args) => {
65
+ deps.openPicker(args.event.resume);
66
+ },
67
+ },
68
+ },
69
+ },
70
+ attached: {
71
+ entry: 'openAttached',
72
+ on: {
73
+ OVERLAY: 'overlay',
74
+ },
75
+ },
76
+ overlay: {
77
+ entry: 'openOverlay',
78
+ on: {
79
+ ATTACH: 'attached',
80
+ HOME: 'home',
81
+ OVERLAY: { target: 'overlay', reenter: true },
82
+ HELP: 'help',
83
+ SPAWN: {
84
+ target: 'picker',
85
+ actions: (args) => {
86
+ deps.openPicker(args.event.resume);
87
+ },
88
+ },
89
+ EJECT: {
90
+ target: 'picker-eject',
91
+ actions: (args) => {
92
+ deps.openEject(args.event.sessionID);
93
+ },
94
+ },
95
+ },
96
+ },
97
+ help: {
98
+ entry: 'openHelp',
99
+ on: {
100
+ OVERLAY: 'overlay',
101
+ },
102
+ },
103
+ picker: {
104
+ on: {
105
+ ATTACH: 'attached',
106
+ HOME: 'home',
107
+ OVERLAY: 'overlay',
108
+ },
109
+ },
110
+ 'picker-eject': {
111
+ on: {
112
+ ATTACH: 'attached',
113
+ HOME: 'home',
114
+ OVERLAY: 'overlay',
115
+ },
116
+ },
117
+ },
118
+ });
119
+ }