@zgeoff/atc 0.1.9 → 0.1.11

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.
@@ -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
  }
package/src/index.ts CHANGED
@@ -10,16 +10,25 @@ import { countSessionStates, sortGroupedSessionViews, sortSessionViews } from '.
10
10
  import type { SessionState } from './sessions';
11
11
  import { ansi, cols, drawHelp, drawHome, drawOverlay, drawPicker, drawStatusBar, rows } from './ui';
12
12
 
13
+ type AgentKind = 'claude' | 'grok' | 'codex';
14
+
13
15
  type Mode =
14
16
  | 'home'
15
17
  | 'attached'
16
18
  | 'overlay'
17
19
  | 'help'
20
+ | 'picker-agent'
18
21
  | 'picker-dir'
19
22
  | 'picker-name'
20
23
  | 'picker-prompt'
21
24
  | 'picker-eject';
22
25
 
26
+ const AGENT_PICKS = [
27
+ { agent: 'claude' as const, label: 'Claude' },
28
+ { agent: 'grok' as const, label: 'Grok' },
29
+ { agent: 'codex' as const, label: 'Codex' },
30
+ ];
31
+
23
32
  interface MirrorSession {
24
33
  id: string;
25
34
  name: string;
@@ -31,9 +40,10 @@ interface MirrorSession {
31
40
  unread: boolean;
32
41
  lastMsg: string;
33
42
  createdAt: number;
34
- kind: 'pty' | 'jsonl';
43
+ kind: 'pty' | 'headless';
35
44
  alive: boolean;
36
45
  resumable: boolean;
46
+ agent: AgentKind;
37
47
  }
38
48
 
39
49
  let mode: Mode = 'home';
@@ -58,6 +68,8 @@ let pickerSelected = 0;
58
68
  let spawnDir = '';
59
69
  let spawnName = '';
60
70
  let spawnResume = false;
71
+ let spawnAgent: AgentKind = 'claude';
72
+ let lastUsedAgent: AgentKind = 'claude';
61
73
  const stdout = process.stdout;
62
74
 
63
75
  // Full height: the atc status bar only exists on home/overlay screens;
@@ -251,7 +263,17 @@ function openOverlay() {
251
263
  function renderPicker() {
252
264
  const verb = spawnResume ? 'adopt' : 'spawn';
253
265
 
254
- if (mode === 'picker-dir') {
266
+ if (mode === 'picker-agent') {
267
+ pickerSelected = Math.min(pickerSelected, AGENT_PICKS.length - 1);
268
+
269
+ drawPicker({
270
+ title: `${verb}: agent`,
271
+ items: AGENT_PICKS.map((pick) => pick.label),
272
+ selected: pickerSelected,
273
+ input: pickerInput,
274
+ hint: '↑↓ move · ⏎ select · esc cancel',
275
+ });
276
+ } else if (mode === 'picker-dir') {
255
277
  const items = pickMatches(allDirs, pickerInput).map((d) => formatDir(d));
256
278
 
257
279
  pickerSelected = Math.min(pickerSelected, Math.max(0, Math.min(items.length, 10) - 1));
@@ -295,9 +317,24 @@ function renderPicker() {
295
317
  scheduleStatus();
296
318
  }
297
319
 
298
- async function openDirPicker(resume = false) {
320
+ function openAgentPicker(resume = false) {
299
321
  spawnResume = resume;
322
+ spawnAgent = lastUsedAgent;
323
+ pickerInput = '';
324
+
325
+ pickerSelected = Math.max(
326
+ 0,
327
+ AGENT_PICKS.findIndex((p) => p.agent === lastUsedAgent),
328
+ );
329
+
330
+ mode = 'picker-agent';
300
331
 
332
+ stdout.write(ansi.clear);
333
+
334
+ renderPicker();
335
+ }
336
+
337
+ async function openDirPicker() {
301
338
  let recent: string[] = [];
302
339
 
303
340
  try {
@@ -344,6 +381,7 @@ async function spawnFromPicker(prompt: string) {
344
381
  cols: cols(),
345
382
  rows: ptyRows(),
346
383
  ...(spawnResume ? { resume: true } : {}),
384
+ agent: spawnAgent,
347
385
  });
348
386
 
349
387
  const spawned = toMirrorSession(ok['session']);
@@ -407,9 +445,10 @@ function toMirrorSession(value: unknown): MirrorSession | null {
407
445
  unread: value['unread'],
408
446
  lastMsg: value['lastMsg'],
409
447
  createdAt: value['createdAt'],
410
- kind: value['kind'] === 'jsonl' ? 'jsonl' : 'pty',
448
+ kind: value['kind'] === 'headless' ? 'headless' : 'pty',
411
449
  alive: value['alive'],
412
- resumable: typeof value['claudeId'] === 'string',
450
+ resumable: typeof value['agentSessionID'] === 'string',
451
+ agent: value['agent'] === 'grok' || value['agent'] === 'codex' ? value['agent'] : 'claude',
413
452
  };
414
453
  }
415
454
 
@@ -429,6 +468,7 @@ function upsertMirror(d: Readonly<MirrorSession>) {
429
468
  existing.kind = d.kind;
430
469
  existing.alive = d.alive;
431
470
  existing.resumable = d.resumable;
471
+ existing.agent = d.agent;
432
472
  }
433
473
  }
434
474
 
@@ -487,7 +527,7 @@ function applyDaemonEvent(e: EventMsg) {
487
527
  s.alive = false;
488
528
  }
489
529
 
490
- if (e['kind'] === 'pty' || e['kind'] === 'jsonl') {
530
+ if (e['kind'] === 'pty' || e['kind'] === 'headless') {
491
531
  s.kind = e['kind'];
492
532
  }
493
533
 
@@ -507,7 +547,15 @@ function applyDaemonEvent(e: EventMsg) {
507
547
  s.lastMsg = e['lastMsg'];
508
548
  }
509
549
 
510
- if (typeof e['claudeId'] === 'string') {
550
+ if (e['agent'] === 'grok' || e['agent'] === 'codex' || e['agent'] === 'claude') {
551
+ s.agent = e['agent'];
552
+ }
553
+
554
+ if (typeof e['agentSessionID'] === 'string') {
555
+ if (!s.resumable) {
556
+ lastUsedAgent = s.agent;
557
+ }
558
+
511
559
  s.resumable = true;
512
560
  }
513
561
  }
@@ -735,7 +783,7 @@ function applyOverlayKey(buf: Buffer) {
735
783
  }
736
784
 
737
785
  if (ch === 'n') {
738
- void openDirPicker();
786
+ openAgentPicker();
739
787
 
740
788
  return;
741
789
  }
@@ -772,7 +820,13 @@ function applyOverlayKey(buf: Buffer) {
772
820
  return;
773
821
  }
774
822
 
775
- if (ch === 'H' && sel !== undefined && sel.kind === 'pty' && sel.alive) {
823
+ if (
824
+ ch === 'H' &&
825
+ sel !== undefined &&
826
+ sel.kind === 'pty' &&
827
+ sel.alive &&
828
+ sel.agent === 'claude'
829
+ ) {
776
830
  ejectTarget = sel.id;
777
831
  pickerInput = '';
778
832
  mode = 'picker-eject';
@@ -784,14 +838,14 @@ function applyOverlayKey(buf: Buffer) {
784
838
  return;
785
839
  }
786
840
 
787
- if (ch === 'P' && sel !== undefined && sel.resumable && (sel.kind === 'jsonl' || !sel.alive)) {
841
+ if (ch === 'P' && sel !== undefined && sel.resumable && (sel.kind === 'headless' || !sel.alive)) {
788
842
  void adoptSession(sel.id);
789
843
 
790
844
  return;
791
845
  }
792
846
 
793
847
  if (ch === 'r') {
794
- void openDirPicker(true);
848
+ openAgentPicker(true);
795
849
 
796
850
  return;
797
851
  }
@@ -893,7 +947,7 @@ function applyTextKey(buf: Buffer, onSubmit: () => void, onCancel: () => void) {
893
947
  return;
894
948
  }
895
949
 
896
- if (mode === 'picker-dir') {
950
+ if (mode === 'picker-dir' || mode === 'picker-agent') {
897
951
  if (isDown(buf)) {
898
952
  pickerSelected++;
899
953
 
@@ -933,6 +987,7 @@ const boot = await bootDaemonClient();
933
987
  let client = boot.client;
934
988
  let daemonStale = boot.stale;
935
989
 
990
+ lastUsedAgent = boot.lastUsedAgent;
936
991
  client.onEvent = applyDaemonEvent;
937
992
 
938
993
  await refreshMirror();
@@ -989,6 +1044,7 @@ async function restartDaemon() {
989
1044
 
990
1045
  client = next.client;
991
1046
  daemonStale = next.stale;
1047
+ lastUsedAgent = next.lastUsedAgent;
992
1048
  client.onEvent = applyDaemonEvent;
993
1049
 
994
1050
  await sendQuiet('fleet.restore', { cols: cols(), rows: ptyRows() });
@@ -1032,13 +1088,13 @@ process.stdin.on('data', (buf: Buffer) => {
1032
1088
  const ch = buf.toString();
1033
1089
 
1034
1090
  if (ch === 'n') {
1035
- void openDirPicker();
1091
+ openAgentPicker();
1036
1092
 
1037
1093
  return;
1038
1094
  }
1039
1095
 
1040
1096
  if (ch === 'r') {
1041
- void openDirPicker(true);
1097
+ openAgentPicker(true);
1042
1098
 
1043
1099
  return;
1044
1100
  }
@@ -1065,6 +1121,20 @@ process.stdin.on('data', (buf: Buffer) => {
1065
1121
 
1066
1122
  return;
1067
1123
  }
1124
+ case 'picker-agent': {
1125
+ applyTextKey(
1126
+ buf,
1127
+ () => {
1128
+ spawnAgent = AGENT_PICKS[pickerSelected]?.agent ?? 'claude';
1129
+ void openDirPicker();
1130
+ },
1131
+ () => {
1132
+ toBase();
1133
+ },
1134
+ );
1135
+
1136
+ return;
1137
+ }
1068
1138
  case 'picker-dir': {
1069
1139
  applyTextKey(
1070
1140
  buf,
@@ -1090,7 +1160,18 @@ process.stdin.on('data', (buf: Buffer) => {
1090
1160
  renderPicker();
1091
1161
  },
1092
1162
  () => {
1093
- toBase();
1163
+ pickerInput = '';
1164
+
1165
+ pickerSelected = Math.max(
1166
+ 0,
1167
+ AGENT_PICKS.findIndex((p) => p.agent === spawnAgent),
1168
+ );
1169
+
1170
+ mode = 'picker-agent';
1171
+
1172
+ stdout.write(ansi.clear);
1173
+
1174
+ renderPicker();
1094
1175
  },
1095
1176
  );
1096
1177
 
package/src/mcp-server.ts CHANGED
@@ -43,13 +43,18 @@ const TOOLS: readonly MCPTool[] = [
43
43
  {
44
44
  name: 'atc_session_spawn',
45
45
  description:
46
- 'Spawn a new Claude Code session in a directory. Returns the new session descriptor. Give it a prompt to start it working immediately.',
46
+ 'Spawn a new session in a directory. Optional agent is claude, grok, or codex; omitted agent is always Claude, never the TUI last-used value. Returns the new session descriptor. Give it a prompt to start it working immediately.',
47
47
  inputSchema: {
48
48
  type: 'object',
49
49
  properties: {
50
50
  cwd: { type: 'string', description: 'Absolute path of the working directory' },
51
51
  name: { type: 'string', description: 'Session name; defaults to the directory basename' },
52
52
  prompt: { type: 'string', description: 'First message for the session' },
53
+ agent: {
54
+ type: 'string',
55
+ enum: ['claude', 'grok', 'codex'],
56
+ description: 'Which agent CLI to spawn; defaults to claude',
57
+ },
53
58
  },
54
59
  required: ['cwd'],
55
60
  additionalProperties: false,
@@ -236,10 +241,24 @@ async function runTool(
236
241
  return JSON.stringify(ok['sessions'], null, 2);
237
242
  }
238
243
  case 'atc_session_spawn': {
244
+ const rawAgent = args['agent'];
245
+
246
+ if (
247
+ rawAgent !== undefined &&
248
+ rawAgent !== 'claude' &&
249
+ rawAgent !== 'grok' &&
250
+ rawAgent !== 'codex'
251
+ ) {
252
+ throw new Error("agent must be 'claude', 'grok', or 'codex'");
253
+ }
254
+
239
255
  const ok = await client.sendRequest('session.spawn', {
240
256
  cwd: args['cwd'],
241
257
  ...(typeof args['name'] === 'string' ? { name: args['name'] } : {}),
242
258
  ...(typeof args['prompt'] === 'string' ? { prompt: args['prompt'] } : {}),
259
+ ...(rawAgent === 'claude' || rawAgent === 'grok' || rawAgent === 'codex'
260
+ ? { agent: rawAgent }
261
+ : {}),
243
262
  cols: 100,
244
263
  rows: 30,
245
264
  });
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Canonicalize a hook event name to PascalCase. Claude already sends
3
+ * PascalCase (`SessionStart`); Grok sends snake_case (`session_start`).
4
+ * Restore stagger and the adapter switch compare the PascalCase form.
5
+ */
6
+ export function normalizeHookEventName(raw: string): string {
7
+ return raw
8
+ .split('_')
9
+ .map((part) => `${part.charAt(0).toUpperCase()}${part.slice(1)}`)
10
+ .join('');
11
+ }
@@ -8,7 +8,7 @@ interface TabCandidate {
8
8
  readonly lastAttachedAt: number;
9
9
  readonly createdAt: number;
10
10
  readonly alive: boolean;
11
- readonly kind: 'pty' | 'jsonl';
11
+ readonly kind: 'pty' | 'headless';
12
12
  }
13
13
 
14
14
  /**
@@ -0,0 +1,26 @@
1
+ import { buildCLICommand } from './build-cli-command';
2
+
3
+ const CODEX_HOOK_EVENTS = [
4
+ 'SessionStart',
5
+ 'UserPromptSubmit',
6
+ 'PermissionRequest',
7
+ 'Stop',
8
+ ] as const;
9
+
10
+ /**
11
+ * Print the Codex hook entries to stdout. The operator merges them into
12
+ * `$CODEX_HOME/hooks.json` and trusts them once in the Codex TUI — Codex
13
+ * parses untrusted hooks but never runs them. atc never writes that path.
14
+ */
15
+ export function printCodexHookFile(): void {
16
+ process.stdout.write(buildCodexHookFile());
17
+ }
18
+
19
+ function buildCodexHookFile(): string {
20
+ const cmd = buildCLICommand('hook-report');
21
+ const buildEntry = (timeout: number) => [{ hooks: [{ type: 'command', command: cmd, timeout }] }];
22
+ const hooks = Object.fromEntries(CODEX_HOOK_EVENTS.map((event) => [event, buildEntry(5)]));
23
+
24
+ // Codex caps SessionEnd hooks at three seconds.
25
+ return `${JSON.stringify({ hooks: { ...hooks, SessionEnd: buildEntry(3) } }, null, 2)}\n`;
26
+ }
@@ -0,0 +1,27 @@
1
+ import { buildCLICommand } from './build-cli-command';
2
+
3
+ const GROK_HOOK_EVENTS = [
4
+ 'SessionStart',
5
+ 'SessionEnd',
6
+ 'UserPromptSubmit',
7
+ 'Stop',
8
+ 'StopFailure',
9
+ 'StopCancelled',
10
+ 'Notification',
11
+ ] as const;
12
+
13
+ /**
14
+ * Print the Grok hook file to stdout. The operator copies it to
15
+ * `$GROK_HOME/hooks/atc-reporter.json`; atc never writes that path.
16
+ */
17
+ export function printGrokHookFile(): void {
18
+ process.stdout.write(buildGrokHookFile());
19
+ }
20
+
21
+ function buildGrokHookFile(): string {
22
+ const cmd = buildCLICommand('hook-report');
23
+ const entry = [{ hooks: [{ type: 'command', command: cmd, timeout: 5 }] }];
24
+ const hooks = Object.fromEntries(GROK_HOOK_EVENTS.map((event) => [event, entry]));
25
+
26
+ return `${JSON.stringify({ hooks }, null, 2)}\n`;
27
+ }
package/src/protocol.ts CHANGED
@@ -5,7 +5,7 @@ import { isRecord } from './report';
5
5
  * per line. Three message kinds, distinguished by which fields are present —
6
6
  * request (id + m), response (id + ok or err), event (ev).
7
7
  */
8
- export const PROTOCOL_V = 1;
8
+ export const PROTOCOL_V = 2;
9
9
 
10
10
  // Control lines are capped before buffering; PTY output is split into chunks
11
11
  // so a queued response is delayed by at most one chunk.