@zgeoff/atc 0.1.9 → 0.1.10

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/src/daemon.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { existsSync, unlinkSync, writeFileSync } from 'node:fs';
2
- import type { AgentAdapter } from './agent-adapter';
1
+ import { unlinkSync, writeFileSync } from 'node:fs';
2
+ import type { AgentAdapter, AgentKind } from './agent-adapter';
3
3
  import { AttachRegistry } from './attach-registry';
4
4
  import type { Dims } from './attach-registry';
5
5
  import { DaemonConnection } from './daemon-connection';
@@ -21,6 +21,10 @@ export interface DaemonOptions {
21
21
  readonly build: string;
22
22
  readonly adapter: AgentAdapter;
23
23
 
24
+ // Optional per-kind adapters. Lookup never falls back across kinds: a
25
+ // grok session with no grok adapter is unsupported, not a Claude spawn.
26
+ readonly adapters?: Partial<Readonly<Record<AgentKind, AgentAdapter>>>;
27
+
24
28
  // SQLite path for daemon state; a fleet.json at legacyFleetPath seeds the
25
29
  // fleet table once so upgrading keeps the restorable fleet.
26
30
  readonly dbPath: string;
@@ -35,11 +39,6 @@ export interface DaemonOptions {
35
39
  // Outbound queue capacity per client; small values force desync in tests.
36
40
  readonly queueBytes?: number;
37
41
 
38
- // Runs one headless turn over a session; the real one drives the Agent
39
- // SDK, which spawns real claude — a boundary tests never cross. Absent
40
- // means eject is unsupported.
41
- readonly headlessRunner?: HeadlessRunner;
42
-
43
42
  // How long an eject waits for the dying terminal to report SessionEnd
44
43
  // before starting the headless run anyway.
45
44
  readonly ejectSettleMs?: number;
@@ -56,24 +55,6 @@ export interface DaemonOptions {
56
55
  readonly onQuit?: () => void;
57
56
  }
58
57
 
59
- interface HeadlessRunRequest {
60
- readonly cwd: string;
61
- readonly prompt: string;
62
- readonly resume?: string;
63
- readonly permissionMode?: string;
64
- }
65
-
66
- interface HeadlessRunEvents {
67
- readonly onOutput: (text: string) => void;
68
- readonly onDone: (summary: string) => void;
69
- readonly onNeedsYou: (msg: string) => void;
70
- }
71
-
72
- export type HeadlessRunner = (
73
- opts: HeadlessRunRequest,
74
- hooks: HeadlessRunEvents,
75
- ) => { readonly stop: () => void };
76
-
77
58
  export interface DaemonHandle {
78
59
  readonly stop: () => void;
79
60
  }
@@ -95,12 +76,7 @@ export function startDaemon(opts: DaemonOptions): DaemonHandle {
95
76
  }
96
77
 
97
78
  const store = new StateStore(opts.dbPath, opts.legacyFleetPath);
98
-
99
- const mgr =
100
- opts.statusPath === undefined
101
- ? new SessionManager(opts.adapter, store)
102
- : new SessionManager(opts.adapter, store, opts.statusPath);
103
-
79
+ const mgr = new SessionManager(opts.adapter, store, opts.statusPath, opts.adapters ?? {});
104
80
  const clients = new Set<DaemonConnection>();
105
81
 
106
82
  const emitEvent = (event: EventMsg) => {
@@ -165,10 +141,10 @@ export function startDaemon(opts: DaemonOptions): DaemonHandle {
165
141
  };
166
142
 
167
143
  const startHeadlessTurn = (sessionID: string, prompt: string): boolean => {
168
- const runner = opts.headlessRunner;
169
144
  const s = mgr.sessions.find((x) => x.id === sessionID);
145
+ const runner = s === undefined ? null : (mgr.findAdapter(s.agent)?.headlessRunner ?? null);
170
146
 
171
- if (runner === undefined || s === undefined || headlessRuns.has(sessionID)) {
147
+ if (s === undefined || runner === null || headlessRuns.has(sessionID)) {
172
148
  return false;
173
149
  }
174
150
 
@@ -178,7 +154,7 @@ export function startDaemon(opts: DaemonOptions): DaemonHandle {
178
154
  {
179
155
  cwd: s.cwd,
180
156
  prompt,
181
- ...(s.claudeId === undefined ? {} : { resume: s.claudeId }),
157
+ ...(s.agentSessionID === undefined ? {} : { resume: s.agentSessionID }),
182
158
  permissionMode: 'auto',
183
159
  },
184
160
  {
@@ -211,11 +187,13 @@ export function startDaemon(opts: DaemonOptions): DaemonHandle {
211
187
  const resizeTimers = new Map<string, ReturnType<typeof setTimeout>>();
212
188
  const detectTimers = new Map<string, ReturnType<typeof setTimeout>>();
213
189
  const restoreTimers = new Set<ReturnType<typeof setTimeout>>();
190
+ const pendingLastUsed = new Set<string>();
214
191
 
215
192
  // The screen tier of the detector stack: once a session's output has
216
193
  // quiesced, judge the serialized screen and flip running/needs_you.
217
194
  const scheduleDetect = (sessionID: string) => {
218
- const detector = opts.adapter.screenDetector;
195
+ const s = mgr.sessions.find((x) => x.id === sessionID);
196
+ const detector = s === undefined ? null : (mgr.findAdapter(s.agent)?.screenDetector ?? null);
219
197
 
220
198
  if (detector === null) {
221
199
  return;
@@ -306,7 +284,8 @@ export function startDaemon(opts: DaemonOptions): DaemonHandle {
306
284
  };
307
285
 
308
286
  const applyScreenJudgment = async (sessionID: string) => {
309
- const detector = opts.adapter.screenDetector;
287
+ const s = mgr.sessions.find((x) => x.id === sessionID);
288
+ const detector = s === undefined ? null : (mgr.findAdapter(s.agent)?.screenDetector ?? null);
310
289
  const model = screens.get(sessionID);
311
290
 
312
291
  if (detector === null || model === undefined) {
@@ -416,8 +395,9 @@ export function startDaemon(opts: DaemonOptions): DaemonHandle {
416
395
  unread: s.unread,
417
396
  lastMsg: s.lastMsg,
418
397
  kind: s.kind,
419
- alive: s.pty !== null || (s.kind === 'jsonl' && s.state !== 'exited'),
420
- ...(s.claudeId === undefined ? {} : { claudeId: s.claudeId }),
398
+ alive: s.pty !== null || (s.kind === 'headless' && s.state !== 'exited'),
399
+ ...(s.agentSessionID === undefined ? {} : { agentSessionID: s.agentSessionID }),
400
+ agent: s.agent,
421
401
  pinned: s.pinned,
422
402
  lastAttachedAt: s.lastAttachedAt,
423
403
  }),
@@ -439,17 +419,22 @@ export function startDaemon(opts: DaemonOptions): DaemonHandle {
439
419
  store.recordEvent(e);
440
420
  }
441
421
 
442
- if (e.event === 'SessionEnd') {
422
+ const kind = mgr.applyHook(e);
423
+
424
+ if (kind === 'ended') {
443
425
  pendingEjects.get(e.atcId)?.();
444
426
  }
445
427
 
446
428
  // A revived session announcing itself is the cue a staggered restore
447
429
  // waits on before booting the next one.
448
- if (e.event === 'SessionStart') {
430
+ if (kind === 'started') {
449
431
  bootWaiters.get(e.atcId)?.();
450
- }
432
+ const started = mgr.sessions.find((s) => s.id === e.atcId);
451
433
 
452
- mgr.applyHook(e);
434
+ if (started !== undefined && pendingLastUsed.delete(started.id)) {
435
+ store.writeLastUsedAgent(started.agent);
436
+ }
437
+ }
453
438
  }, opts.reporterSocketPath);
454
439
 
455
440
  const ctx: DaemonContext = {
@@ -457,9 +442,12 @@ export function startDaemon(opts: DaemonOptions): DaemonHandle {
457
442
  collectSessions: () => mgr.collectDescriptors(),
458
443
  collectSpawnDirs: () => store.collectSpawnDirs(),
459
444
  collectFleet: () => store.loadFleet(),
445
+ loadLastUsedAgent: () => store.loadLastUsedAgent(),
446
+ findAdapter: (kind) => mgr.findAdapter(kind),
460
447
  spawnSession: (p) => {
461
- const s = mgr.spawn(p.cwd, p.name, p.prompt, p.cols, p.rows, p.resume, p.namedBy);
448
+ const s = mgr.spawn(p.cwd, p.name, p.prompt, p.cols, p.rows, p.resume, p.namedBy, p.agent);
462
449
 
450
+ pendingLastUsed.add(s.id);
463
451
  ptyDims.set(s.id, { cols: p.cols, rows: p.rows });
464
452
  screens.set(s.id, new ScreenModel(p.cols, p.rows));
465
453
  store.recordSpawnDir(p.cwd);
@@ -487,7 +475,15 @@ export function startDaemon(opts: DaemonOptions): DaemonHandle {
487
475
  return true;
488
476
  },
489
477
  ejectSession: (id, prompt) => {
490
- if (opts.headlessRunner === undefined) {
478
+ const s = mgr.sessions.find((x) => x.id === id);
479
+
480
+ if (s === undefined) {
481
+ return 'missing';
482
+ }
483
+
484
+ const adapter = mgr.findAdapter(s.agent);
485
+
486
+ if (adapter === null || adapter.headlessRunner === null) {
491
487
  return 'unsupported';
492
488
  }
493
489
 
@@ -589,7 +585,7 @@ export function startDaemon(opts: DaemonOptions): DaemonHandle {
589
585
  return 'missing';
590
586
  }
591
587
 
592
- if (s.kind === 'jsonl') {
588
+ if (s.kind === 'headless') {
593
589
  if (headlessRuns.has(sessionID)) {
594
590
  return 'busy';
595
591
  }
@@ -619,11 +615,11 @@ export function startDaemon(opts: DaemonOptions): DaemonHandle {
619
615
  getEffectiveDims: (sessionID) =>
620
616
  attachments.findEffectiveDims(sessionID) ?? ptyDims.get(sessionID) ?? { cols: 80, rows: 24 },
621
617
  restoreFleet: (cols, rows) => {
622
- const hasSession = (claudeId: string) =>
618
+ const hasSession = (agentSessionID: string) =>
623
619
  mgr.sessions.some(
624
620
  (s) =>
625
- s.claudeId === claudeId &&
626
- (s.pty !== null || (s.kind === 'jsonl' && s.state !== 'exited')),
621
+ s.agentSessionID === agentSessionID &&
622
+ (s.pty !== null || (s.kind === 'headless' && s.state !== 'exited')),
627
623
  );
628
624
 
629
625
  const recency = store.collectFleetRecency();
@@ -633,9 +629,9 @@ export function startDaemon(opts: DaemonOptions): DaemonHandle {
633
629
  // reported an event keep their stored order at the end.
634
630
  const entries = store
635
631
  .loadFleet()
636
- .filter((entry) => !hasSession(entry.claudeId))
632
+ .filter((entry) => !hasSession(entry.agentSessionID))
637
633
  .toSorted((a, b) =>
638
- (recency.get(b.claudeId) ?? '').localeCompare(recency.get(a.claudeId) ?? ''),
634
+ (recency.get(b.agentSessionID) ?? '').localeCompare(recency.get(a.agentSessionID) ?? ''),
639
635
  );
640
636
 
641
637
  // The whole fleet registers as terminal-less sessions up front, so the
@@ -749,18 +745,23 @@ export function startDaemon(opts: DaemonOptions): DaemonHandle {
749
745
  return { stop: stopDaemon };
750
746
  }
751
747
 
752
- // A session reporting a transcript path that does not exist on disk yet has
753
- // nothing to resume — claude exits immediately on such a resume, which
754
- // reads as the revive silently failing. A session that never reported a
755
- // path (foreign adapters) passes; the spawn itself will tell the truth.
756
748
  function hasResumableTranscript(mgr: SessionManager, id: string): boolean {
757
749
  const s = mgr.sessions.find((x) => x.id === id);
758
750
 
759
- if (s === undefined || s.transcriptSource === undefined) {
760
- return true;
751
+ if (s === undefined) {
752
+ return false;
753
+ }
754
+
755
+ const adapter = mgr.findAdapter(s.agent);
756
+
757
+ if (adapter === null) {
758
+ return false;
761
759
  }
762
760
 
763
- return existsSync(s.transcriptSource);
761
+ return adapter.canResume({
762
+ ...(s.agentSessionID === undefined ? {} : { agentSessionID: s.agentSessionID }),
763
+ ...(s.transcriptSource === undefined ? {} : { transcriptSource: s.transcriptSource }),
764
+ });
764
765
  }
765
766
 
766
767
  function getDescriptor(mgr: SessionManager, id: string): SessionDescriptor {
@@ -0,0 +1,9 @@
1
+ import type { AgentKind } from './agent-adapter';
2
+
3
+ /**
4
+ * The reserved overlay column after unread: a `g` for Grok, a space for
5
+ * Claude, so session names stay aligned across a mixed fleet.
6
+ */
7
+ export function formatOverlayAgentMark(agent: AgentKind): string {
8
+ return agent === 'grok' ? 'g' : ' ';
9
+ }
@@ -0,0 +1,274 @@
1
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import type {
5
+ AdapterEvent,
6
+ AgentAdapter,
7
+ NameUpdate,
8
+ ResumeCheck,
9
+ SpawnOptions,
10
+ SpawnPlan,
11
+ } from './agent-adapter';
12
+ import type { Config } from './config';
13
+ import type { HookEvent } from './hooks';
14
+ import { normalizeHookEventName } from './normalize-hook-event';
15
+ import { isRecord } from './report';
16
+
17
+ interface GrokSessionHookState {
18
+ latestPromptID?: string;
19
+ lastKind: AdapterEvent['kind'];
20
+ }
21
+
22
+ /**
23
+ * The Grok Build adapter: spawn arguments, hook payload mapping,
24
+ * resume semantics, and summary.json name-pulling.
25
+ */
26
+ export class GrokAdapter implements AgentAdapter {
27
+ readonly kind = 'grok';
28
+
29
+ // Grok has no headless handoff.
30
+ readonly headlessRunner = null;
31
+
32
+ // Grok's hooks are authoritative; no screen heuristics needed.
33
+ readonly screenDetector = null;
34
+
35
+ private readonly config: Config;
36
+
37
+ private readonly hookState = new Map<string, GrokSessionHookState>();
38
+
39
+ constructor(config: Config) {
40
+ this.config = config;
41
+ }
42
+
43
+ planSpawn(opts: SpawnOptions): SpawnPlan {
44
+ return {
45
+ bin: this.config.grokBin,
46
+ args: [
47
+ ...this.config.grokArgs.filter((arg) => arg !== '--leader' && arg !== '--no-leader'),
48
+ '--no-leader',
49
+ ...(typeof opts.resume === 'string' ? ['--resume', opts.resume] : []),
50
+ ...(opts.prompt === '' ? [] : [opts.prompt]),
51
+ ],
52
+ };
53
+ }
54
+
55
+ 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'];
60
+
61
+ const base: AdapterEvent = {
62
+ kind: 'heartbeat',
63
+ ...(typeof sessionID === 'string' ? { agentSessionID: sessionID } : {}),
64
+ };
65
+
66
+ const named: AdapterEvent = {
67
+ ...base,
68
+ ...(typeof sessionID === 'string' && typeof cwd === 'string'
69
+ ? { nameSource: buildGrokNameSource(sessionID, cwd) }
70
+ : {}),
71
+ };
72
+
73
+ if (typeof subagentType === 'string' && subagentType !== '') {
74
+ return this.emitHook(e.atcId, { ...base, kind: 'heartbeat' });
75
+ }
76
+
77
+ const event = normalizeHookEventName(e.event);
78
+ const prior = this.hookState.get(e.atcId);
79
+
80
+ const stale =
81
+ typeof promptID === 'string' &&
82
+ prior?.latestPromptID !== undefined &&
83
+ promptID !== prior.latestPromptID;
84
+
85
+ switch (event) {
86
+ case 'SessionStart': {
87
+ return this.emitHook(e.atcId, { ...named, kind: 'started' });
88
+ }
89
+ 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;
93
+
94
+ return this.emitHook(
95
+ e.atcId,
96
+ {
97
+ ...named,
98
+ kind: 'prompt-submitted',
99
+ ...(preview === '' ? {} : { message: preview, detail: truncateDetail(preview) }),
100
+ },
101
+ submittedID,
102
+ );
103
+ }
104
+ case 'Notification': {
105
+ const notificationType = e.payload['notificationType'];
106
+ const message = e.payload['message'];
107
+
108
+ if (notificationType === 'permission_prompt') {
109
+ return this.emitHook(e.atcId, {
110
+ ...base,
111
+ kind: 'needs-input',
112
+ ...(typeof message === 'string' && message !== ''
113
+ ? { message, detail: truncateDetail(message) }
114
+ : {}),
115
+ });
116
+ }
117
+
118
+ if (notificationType === 'idle_prompt') {
119
+ const kind = prior?.lastKind === 'needs-input' ? 'heartbeat' : 'turn-done';
120
+
121
+ return this.emitHook(e.atcId, { ...named, kind });
122
+ }
123
+
124
+ return this.emitHook(e.atcId, base);
125
+ }
126
+ case 'Stop': {
127
+ if (e.payload['reason'] !== 'end_turn' || stale) {
128
+ return this.emitHook(e.atcId, base);
129
+ }
130
+
131
+ return this.emitHook(e.atcId, buildTurnDoneEvent(named, e.payload));
132
+ }
133
+ case 'StopFailure':
134
+ case 'StopCancelled': {
135
+ if (stale) {
136
+ return this.emitHook(e.atcId, base);
137
+ }
138
+
139
+ return this.emitHook(e.atcId, buildTurnDoneEvent(named, e.payload));
140
+ }
141
+ case 'SessionEnd': {
142
+ this.hookState.delete(e.atcId);
143
+
144
+ return { ...base, kind: 'ended' };
145
+ }
146
+ default: {
147
+ return this.emitHook(e.atcId, base);
148
+ }
149
+ }
150
+ }
151
+
152
+ loadName(source: string, namedBy: 'user' | 'auto' | 'agent'): Promise<NameUpdate | null> {
153
+ const parsed = readSummary(source) ?? readSummary(findGrokSummary(source));
154
+
155
+ if (parsed === null) {
156
+ return Promise.resolve(null);
157
+ }
158
+
159
+ const generated = parsed['generated_title'];
160
+ const summary = parsed['session_summary'];
161
+ const title = typeof generated === 'string' ? generated : '';
162
+ const sessionSummary = typeof summary === 'string' ? summary : '';
163
+
164
+ if (parsed['title_is_manual'] === true && title !== '') {
165
+ return Promise.resolve({ name: title, namedBy: 'agent' });
166
+ }
167
+
168
+ if (namedBy !== 'user' && title !== '') {
169
+ return Promise.resolve({ name: title });
170
+ }
171
+
172
+ if (namedBy !== 'user' && sessionSummary !== '') {
173
+ return Promise.resolve({ name: sessionSummary });
174
+ }
175
+
176
+ return Promise.resolve(null);
177
+ }
178
+
179
+ canResume(session: ResumeCheck): boolean {
180
+ return session.agentSessionID !== undefined;
181
+ }
182
+
183
+ // Shell command that re-opens this session outside atc (or anywhere).
184
+ buildResumeCommand(cwd: string, agentSessionID: string | undefined): string | null {
185
+ const resume = agentSessionID === undefined ? 'grok' : `grok --resume ${agentSessionID}`;
186
+ const quoted = cwd.replaceAll("'", String.raw`'\''`);
187
+
188
+ return `cd '${quoted}' && ${resume}`;
189
+ }
190
+
191
+ private emitHook(atcID: string, event: Readonly<AdapterEvent>, promptID?: string): AdapterEvent {
192
+ const prior = this.hookState.get(atcID);
193
+ const latestPromptID = promptID ?? prior?.latestPromptID;
194
+
195
+ const tracked =
196
+ event.kind === 'needs-input' ||
197
+ event.kind === 'prompt-submitted' ||
198
+ event.kind === 'turn-done'
199
+ ? event.kind
200
+ : (prior?.lastKind ?? event.kind);
201
+
202
+ this.hookState.set(atcID, {
203
+ lastKind: tracked,
204
+ ...(latestPromptID === undefined ? {} : { latestPromptID }),
205
+ });
206
+
207
+ return event;
208
+ }
209
+ }
210
+
211
+ function resolveGrokHome(): string {
212
+ const home = process.env['GROK_HOME'];
213
+
214
+ return home !== undefined && home !== '' ? home : join(homedir(), '.grok');
215
+ }
216
+
217
+ function buildGrokNameSource(sessionID: string, cwd: string): string {
218
+ return join(resolveGrokHome(), 'sessions', encodeURIComponent(cwd), sessionID, 'summary.json');
219
+ }
220
+
221
+ function buildTurnDoneEvent(
222
+ named: Readonly<AdapterEvent>,
223
+ payload: Readonly<Record<string, unknown>>,
224
+ ): AdapterEvent {
225
+ const lastMessage = payload['lastAssistantMessage'];
226
+
227
+ return {
228
+ ...named,
229
+ kind: 'turn-done',
230
+ ...(typeof lastMessage === 'string' && lastMessage !== ''
231
+ ? { detail: truncateDetail(lastMessage) }
232
+ : {}),
233
+ };
234
+ }
235
+
236
+ function readSummary(source: string | null): Record<string, unknown> | null {
237
+ if (source === null) {
238
+ return null;
239
+ }
240
+
241
+ try {
242
+ const parsed: unknown = JSON.parse(readFileSync(source, 'utf8'));
243
+
244
+ return isRecord(parsed) ? parsed : null;
245
+ } catch {
246
+ return null;
247
+ }
248
+ }
249
+
250
+ function findGrokSummary(source: string): string | null {
251
+ const sessionID = source.split('/').at(-2);
252
+
253
+ if (sessionID === undefined || sessionID === '') {
254
+ return null;
255
+ }
256
+
257
+ const root = join(resolveGrokHome(), 'sessions');
258
+
259
+ try {
260
+ for (const group of readdirSync(root)) {
261
+ const candidate = join(root, group, sessionID, 'summary.json');
262
+
263
+ if (existsSync(candidate)) {
264
+ return candidate;
265
+ }
266
+ }
267
+ } catch {}
268
+
269
+ return null;
270
+ }
271
+
272
+ function truncateDetail(text: string): string {
273
+ return text.length <= 600 ? text : `${text.slice(0, 599)}…`;
274
+ }
@@ -1,9 +1,11 @@
1
+ import { normalizeHookEventName } from './normalize-hook-event';
1
2
  import { isRecord, sendReport } from './report';
2
3
 
3
4
  /**
4
- * Runs as a Claude Code hook inside wrangled sessions. Reads the hook event
5
- * from stdin and forwards it to the atc unix socket. Always exits 0 so it
6
- * never blocks the session it reports on.
5
+ * Runs as a hook inside wrangled sessions. Reads the hook event from stdin
6
+ * (Claude snake_case keys or Grok camelCase keys) and forwards a PascalCase
7
+ * event name to the atc unix socket. Always exits 0 so it never blocks the
8
+ * session it reports on.
7
9
  */
8
10
  export async function runHookReport(): Promise<void> {
9
11
  const sock = process.env['ATC_SOCKET'];
@@ -22,7 +24,9 @@ export async function runHookReport(): Promise<void> {
22
24
  }
23
25
  } catch {}
24
26
 
25
- const line = `${JSON.stringify({ atcId, event: payload['hook_event_name'], payload })}\n`;
27
+ const rawName = payload['hook_event_name'] ?? payload['hookEventName'];
28
+ const event = typeof rawName === 'string' ? normalizeHookEventName(rawName) : rawName;
29
+ const line = `${JSON.stringify({ atcId, event, payload })}\n`;
26
30
 
27
31
  await sendReport(sock, line, 2000);
28
32
  }