@zgeoff/atc 0.1.8 → 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.
@@ -1,4 +1,5 @@
1
1
  import { basename } from 'node:path';
2
+ import type { AgentAdapter, AgentKind } from './agent-adapter';
2
3
  import type { Dims } from './attach-registry';
3
4
  import { OutboundQueue } from './outbound-queue';
4
5
  import type { SocketWriter } from './outbound-queue';
@@ -15,7 +16,7 @@ interface SpawnParams {
15
16
  readonly rows: number;
16
17
  readonly resume: boolean | string;
17
18
  readonly namedBy: 'user' | 'auto';
18
- readonly group?: string;
19
+ readonly agent: AgentKind;
19
20
  }
20
21
 
21
22
  export interface DaemonContext {
@@ -23,9 +24,11 @@ export interface DaemonContext {
23
24
  readonly collectSessions: () => SessionDescriptor[];
24
25
  readonly collectSpawnDirs: () => string[];
25
26
  readonly collectFleet: () => FleetEntry[];
27
+ readonly loadLastUsedAgent: () => AgentKind;
28
+ readonly findAdapter: (kind: AgentKind) => AgentAdapter | null;
26
29
  readonly spawnSession: (p: SpawnParams) => SessionDescriptor;
27
30
  readonly killSession: (id: string) => boolean;
28
- readonly updateSession: (id: string, name?: string, group?: string) => boolean;
31
+ readonly updateSession: (id: string, name?: string, pinned?: boolean) => boolean;
29
32
  readonly quitDaemon: () => void;
30
33
  readonly ackSession: (id: string) => boolean;
31
34
  readonly buildResumeCommand: (id: string) => string | null;
@@ -225,9 +228,9 @@ export class DaemonConnection {
225
228
  case 'session.update': {
226
229
  const sessionID = typeof req.p?.['session'] === 'string' ? req.p['session'] : '';
227
230
  const name = typeof req.p?.['name'] === 'string' ? req.p['name'] : undefined;
228
- const group = typeof req.p?.['group'] === 'string' ? req.p['group'] : undefined;
231
+ const pinned = typeof req.p?.['pinned'] === 'boolean' ? req.p['pinned'] : undefined;
229
232
 
230
- if (this.ctx.updateSession(sessionID, name, group)) {
233
+ if (this.ctx.updateSession(sessionID, name, pinned)) {
231
234
  this.sendOk(req.id, {});
232
235
  } else {
233
236
  this.sendErr(req.id, 'no_such_session', `no session '${sessionID}'`);
@@ -270,7 +273,7 @@ export class DaemonConnection {
270
273
  if (result === 'ok') {
271
274
  this.sendOk(req.id, {});
272
275
  } else if (result === 'unsupported') {
273
- this.sendErr(req.id, 'unsupported', 'this daemon has no headless runner');
276
+ this.sendErr(req.id, 'unsupported', "this session's agent has no headless handoff");
274
277
  } else if (result === 'no_transcript') {
275
278
  this.sendErr(
276
279
  req.id,
@@ -370,7 +373,21 @@ export class DaemonConnection {
370
373
  resume = rawResume;
371
374
  }
372
375
 
373
- const group = typeof req.p?.['group'] === 'string' ? req.p['group'] : '';
376
+ const rawAgent = req.p?.['agent'];
377
+
378
+ if (rawAgent !== undefined && rawAgent !== 'claude' && rawAgent !== 'grok') {
379
+ this.sendErr(req.id, 'bad_args', "session.spawn agent must be 'claude' or 'grok'");
380
+
381
+ return;
382
+ }
383
+
384
+ const agent: AgentKind = rawAgent === 'grok' ? 'grok' : 'claude';
385
+
386
+ if (this.ctx.findAdapter(agent) === null) {
387
+ this.sendErr(req.id, 'unsupported', `no adapter for agent '${agent}'`);
388
+
389
+ return;
390
+ }
374
391
 
375
392
  const session = this.ctx.spawnSession({
376
393
  cwd,
@@ -380,7 +397,7 @@ export class DaemonConnection {
380
397
  rows: typeof req.p?.['rows'] === 'number' ? req.p['rows'] : 24,
381
398
  resume,
382
399
  namedBy: name === '' ? 'auto' : 'user',
383
- ...(group === '' ? {} : { group }),
400
+ agent,
384
401
  });
385
402
 
386
403
  this.sendOk(req.id, { session });
@@ -521,6 +538,7 @@ export class DaemonConnection {
521
538
  this.sendOk(req.id, {
522
539
  daemon: this.ctx.build,
523
540
  limits: { maxLine: MAX_LINE, maxChunk: MAX_CHUNK },
541
+ lastUsedAgent: this.ctx.loadLastUsedAgent(),
524
542
  });
525
543
 
526
544
  return true;
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,9 +395,11 @@ 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 }),
421
- ...(s.group === undefined ? {} : { group: s.group }),
398
+ alive: s.pty !== null || (s.kind === 'headless' && s.state !== 'exited'),
399
+ ...(s.agentSessionID === undefined ? {} : { agentSessionID: s.agentSessionID }),
400
+ agent: s.agent,
401
+ pinned: s.pinned,
402
+ lastAttachedAt: s.lastAttachedAt,
422
403
  }),
423
404
  renamed: () => ({
424
405
  v: PROTOCOL_V,
@@ -438,17 +419,22 @@ export function startDaemon(opts: DaemonOptions): DaemonHandle {
438
419
  store.recordEvent(e);
439
420
  }
440
421
 
441
- if (e.event === 'SessionEnd') {
422
+ const kind = mgr.applyHook(e);
423
+
424
+ if (kind === 'ended') {
442
425
  pendingEjects.get(e.atcId)?.();
443
426
  }
444
427
 
445
428
  // A revived session announcing itself is the cue a staggered restore
446
429
  // waits on before booting the next one.
447
- if (e.event === 'SessionStart') {
430
+ if (kind === 'started') {
448
431
  bootWaiters.get(e.atcId)?.();
449
- }
432
+ const started = mgr.sessions.find((s) => s.id === e.atcId);
450
433
 
451
- mgr.applyHook(e);
434
+ if (started !== undefined && pendingLastUsed.delete(started.id)) {
435
+ store.writeLastUsedAgent(started.agent);
436
+ }
437
+ }
452
438
  }, opts.reporterSocketPath);
453
439
 
454
440
  const ctx: DaemonContext = {
@@ -456,16 +442,19 @@ export function startDaemon(opts: DaemonOptions): DaemonHandle {
456
442
  collectSessions: () => mgr.collectDescriptors(),
457
443
  collectSpawnDirs: () => store.collectSpawnDirs(),
458
444
  collectFleet: () => store.loadFleet(),
445
+ loadLastUsedAgent: () => store.loadLastUsedAgent(),
446
+ findAdapter: (kind) => mgr.findAdapter(kind),
459
447
  spawnSession: (p) => {
460
- const s = mgr.spawn(p.cwd, p.name, p.prompt, p.cols, p.rows, p.resume, p.namedBy, p.group);
448
+ const s = mgr.spawn(p.cwd, p.name, p.prompt, p.cols, p.rows, p.resume, p.namedBy, p.agent);
461
449
 
450
+ pendingLastUsed.add(s.id);
462
451
  ptyDims.set(s.id, { cols: p.cols, rows: p.rows });
463
452
  screens.set(s.id, new ScreenModel(p.cols, p.rows));
464
453
  store.recordSpawnDir(p.cwd);
465
454
 
466
455
  return getDescriptor(mgr, s.id);
467
456
  },
468
- updateSession: (id, name, group) => mgr.updateSession(id, name, group),
457
+ updateSession: (id, name, pinned) => mgr.updateSession(id, name, pinned),
469
458
  quitDaemon: () => {
470
459
  // The ok response for the quit request must flush before the sockets
471
460
  // close under it.
@@ -486,7 +475,15 @@ export function startDaemon(opts: DaemonOptions): DaemonHandle {
486
475
  return true;
487
476
  },
488
477
  ejectSession: (id, prompt) => {
489
- 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) {
490
487
  return 'unsupported';
491
488
  }
492
489
 
@@ -588,7 +585,7 @@ export function startDaemon(opts: DaemonOptions): DaemonHandle {
588
585
  return 'missing';
589
586
  }
590
587
 
591
- if (s.kind === 'jsonl') {
588
+ if (s.kind === 'headless') {
592
589
  if (headlessRuns.has(sessionID)) {
593
590
  return 'busy';
594
591
  }
@@ -618,11 +615,11 @@ export function startDaemon(opts: DaemonOptions): DaemonHandle {
618
615
  getEffectiveDims: (sessionID) =>
619
616
  attachments.findEffectiveDims(sessionID) ?? ptyDims.get(sessionID) ?? { cols: 80, rows: 24 },
620
617
  restoreFleet: (cols, rows) => {
621
- const hasSession = (claudeId: string) =>
618
+ const hasSession = (agentSessionID: string) =>
622
619
  mgr.sessions.some(
623
620
  (s) =>
624
- s.claudeId === claudeId &&
625
- (s.pty !== null || (s.kind === 'jsonl' && s.state !== 'exited')),
621
+ s.agentSessionID === agentSessionID &&
622
+ (s.pty !== null || (s.kind === 'headless' && s.state !== 'exited')),
626
623
  );
627
624
 
628
625
  const recency = store.collectFleetRecency();
@@ -632,9 +629,9 @@ export function startDaemon(opts: DaemonOptions): DaemonHandle {
632
629
  // reported an event keep their stored order at the end.
633
630
  const entries = store
634
631
  .loadFleet()
635
- .filter((entry) => !hasSession(entry.claudeId))
632
+ .filter((entry) => !hasSession(entry.agentSessionID))
636
633
  .toSorted((a, b) =>
637
- (recency.get(b.claudeId) ?? '').localeCompare(recency.get(a.claudeId) ?? ''),
634
+ (recency.get(b.agentSessionID) ?? '').localeCompare(recency.get(a.agentSessionID) ?? ''),
638
635
  );
639
636
 
640
637
  // The whole fleet registers as terminal-less sessions up front, so the
@@ -748,18 +745,23 @@ export function startDaemon(opts: DaemonOptions): DaemonHandle {
748
745
  return { stop: stopDaemon };
749
746
  }
750
747
 
751
- // A session reporting a transcript path that does not exist on disk yet has
752
- // nothing to resume — claude exits immediately on such a resume, which
753
- // reads as the revive silently failing. A session that never reported a
754
- // path (foreign adapters) passes; the spawn itself will tell the truth.
755
748
  function hasResumableTranscript(mgr: SessionManager, id: string): boolean {
756
749
  const s = mgr.sessions.find((x) => x.id === id);
757
750
 
758
- if (s === undefined || s.transcriptSource === undefined) {
759
- 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;
760
759
  }
761
760
 
762
- return existsSync(s.transcriptSource);
761
+ return adapter.canResume({
762
+ ...(s.agentSessionID === undefined ? {} : { agentSessionID: s.agentSessionID }),
763
+ ...(s.transcriptSource === undefined ? {} : { transcriptSource: s.transcriptSource }),
764
+ });
763
765
  }
764
766
 
765
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
  }