@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/index.ts CHANGED
@@ -10,16 +10,24 @@ 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';
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
+ ];
30
+
23
31
  interface MirrorSession {
24
32
  id: string;
25
33
  name: string;
@@ -31,9 +39,10 @@ interface MirrorSession {
31
39
  unread: boolean;
32
40
  lastMsg: string;
33
41
  createdAt: number;
34
- kind: 'pty' | 'jsonl';
42
+ kind: 'pty' | 'headless';
35
43
  alive: boolean;
36
44
  resumable: boolean;
45
+ agent: AgentKind;
37
46
  }
38
47
 
39
48
  let mode: Mode = 'home';
@@ -58,6 +67,8 @@ let pickerSelected = 0;
58
67
  let spawnDir = '';
59
68
  let spawnName = '';
60
69
  let spawnResume = false;
70
+ let spawnAgent: AgentKind = 'claude';
71
+ let lastUsedAgent: AgentKind = 'claude';
61
72
  const stdout = process.stdout;
62
73
 
63
74
  // Full height: the atc status bar only exists on home/overlay screens;
@@ -251,7 +262,17 @@ function openOverlay() {
251
262
  function renderPicker() {
252
263
  const verb = spawnResume ? 'adopt' : 'spawn';
253
264
 
254
- if (mode === 'picker-dir') {
265
+ if (mode === 'picker-agent') {
266
+ pickerSelected = Math.min(pickerSelected, AGENT_PICKS.length - 1);
267
+
268
+ drawPicker({
269
+ title: `${verb}: agent`,
270
+ items: AGENT_PICKS.map((pick) => pick.label),
271
+ selected: pickerSelected,
272
+ input: pickerInput,
273
+ hint: '↑↓ move · ⏎ select · esc cancel',
274
+ });
275
+ } else if (mode === 'picker-dir') {
255
276
  const items = pickMatches(allDirs, pickerInput).map((d) => formatDir(d));
256
277
 
257
278
  pickerSelected = Math.min(pickerSelected, Math.max(0, Math.min(items.length, 10) - 1));
@@ -295,9 +316,19 @@ function renderPicker() {
295
316
  scheduleStatus();
296
317
  }
297
318
 
298
- async function openDirPicker(resume = false) {
319
+ function openAgentPicker(resume = false) {
299
320
  spawnResume = resume;
321
+ spawnAgent = lastUsedAgent;
322
+ pickerInput = '';
323
+ pickerSelected = lastUsedAgent === 'grok' ? 1 : 0;
324
+ mode = 'picker-agent';
300
325
 
326
+ stdout.write(ansi.clear);
327
+
328
+ renderPicker();
329
+ }
330
+
331
+ async function openDirPicker() {
301
332
  let recent: string[] = [];
302
333
 
303
334
  try {
@@ -344,6 +375,7 @@ async function spawnFromPicker(prompt: string) {
344
375
  cols: cols(),
345
376
  rows: ptyRows(),
346
377
  ...(spawnResume ? { resume: true } : {}),
378
+ agent: spawnAgent,
347
379
  });
348
380
 
349
381
  const spawned = toMirrorSession(ok['session']);
@@ -407,9 +439,10 @@ function toMirrorSession(value: unknown): MirrorSession | null {
407
439
  unread: value['unread'],
408
440
  lastMsg: value['lastMsg'],
409
441
  createdAt: value['createdAt'],
410
- kind: value['kind'] === 'jsonl' ? 'jsonl' : 'pty',
442
+ kind: value['kind'] === 'headless' ? 'headless' : 'pty',
411
443
  alive: value['alive'],
412
- resumable: typeof value['claudeId'] === 'string',
444
+ resumable: typeof value['agentSessionID'] === 'string',
445
+ agent: value['agent'] === 'grok' ? 'grok' : 'claude',
413
446
  };
414
447
  }
415
448
 
@@ -429,6 +462,7 @@ function upsertMirror(d: Readonly<MirrorSession>) {
429
462
  existing.kind = d.kind;
430
463
  existing.alive = d.alive;
431
464
  existing.resumable = d.resumable;
465
+ existing.agent = d.agent;
432
466
  }
433
467
  }
434
468
 
@@ -487,7 +521,7 @@ function applyDaemonEvent(e: EventMsg) {
487
521
  s.alive = false;
488
522
  }
489
523
 
490
- if (e['kind'] === 'pty' || e['kind'] === 'jsonl') {
524
+ if (e['kind'] === 'pty' || e['kind'] === 'headless') {
491
525
  s.kind = e['kind'];
492
526
  }
493
527
 
@@ -507,7 +541,15 @@ function applyDaemonEvent(e: EventMsg) {
507
541
  s.lastMsg = e['lastMsg'];
508
542
  }
509
543
 
510
- if (typeof e['claudeId'] === 'string') {
544
+ if (e['agent'] === 'grok' || e['agent'] === 'claude') {
545
+ s.agent = e['agent'];
546
+ }
547
+
548
+ if (typeof e['agentSessionID'] === 'string') {
549
+ if (!s.resumable) {
550
+ lastUsedAgent = s.agent;
551
+ }
552
+
511
553
  s.resumable = true;
512
554
  }
513
555
  }
@@ -735,7 +777,7 @@ function applyOverlayKey(buf: Buffer) {
735
777
  }
736
778
 
737
779
  if (ch === 'n') {
738
- void openDirPicker();
780
+ openAgentPicker();
739
781
 
740
782
  return;
741
783
  }
@@ -772,7 +814,7 @@ function applyOverlayKey(buf: Buffer) {
772
814
  return;
773
815
  }
774
816
 
775
- if (ch === 'H' && sel !== undefined && sel.kind === 'pty' && sel.alive) {
817
+ if (ch === 'H' && sel !== undefined && sel.kind === 'pty' && sel.alive && sel.agent !== 'grok') {
776
818
  ejectTarget = sel.id;
777
819
  pickerInput = '';
778
820
  mode = 'picker-eject';
@@ -784,14 +826,14 @@ function applyOverlayKey(buf: Buffer) {
784
826
  return;
785
827
  }
786
828
 
787
- if (ch === 'P' && sel !== undefined && sel.resumable && (sel.kind === 'jsonl' || !sel.alive)) {
829
+ if (ch === 'P' && sel !== undefined && sel.resumable && (sel.kind === 'headless' || !sel.alive)) {
788
830
  void adoptSession(sel.id);
789
831
 
790
832
  return;
791
833
  }
792
834
 
793
835
  if (ch === 'r') {
794
- void openDirPicker(true);
836
+ openAgentPicker(true);
795
837
 
796
838
  return;
797
839
  }
@@ -893,7 +935,7 @@ function applyTextKey(buf: Buffer, onSubmit: () => void, onCancel: () => void) {
893
935
  return;
894
936
  }
895
937
 
896
- if (mode === 'picker-dir') {
938
+ if (mode === 'picker-dir' || mode === 'picker-agent') {
897
939
  if (isDown(buf)) {
898
940
  pickerSelected++;
899
941
 
@@ -933,6 +975,7 @@ const boot = await bootDaemonClient();
933
975
  let client = boot.client;
934
976
  let daemonStale = boot.stale;
935
977
 
978
+ lastUsedAgent = boot.lastUsedAgent;
936
979
  client.onEvent = applyDaemonEvent;
937
980
 
938
981
  await refreshMirror();
@@ -989,6 +1032,7 @@ async function restartDaemon() {
989
1032
 
990
1033
  client = next.client;
991
1034
  daemonStale = next.stale;
1035
+ lastUsedAgent = next.lastUsedAgent;
992
1036
  client.onEvent = applyDaemonEvent;
993
1037
 
994
1038
  await sendQuiet('fleet.restore', { cols: cols(), rows: ptyRows() });
@@ -1032,13 +1076,13 @@ process.stdin.on('data', (buf: Buffer) => {
1032
1076
  const ch = buf.toString();
1033
1077
 
1034
1078
  if (ch === 'n') {
1035
- void openDirPicker();
1079
+ openAgentPicker();
1036
1080
 
1037
1081
  return;
1038
1082
  }
1039
1083
 
1040
1084
  if (ch === 'r') {
1041
- void openDirPicker(true);
1085
+ openAgentPicker(true);
1042
1086
 
1043
1087
  return;
1044
1088
  }
@@ -1065,6 +1109,20 @@ process.stdin.on('data', (buf: Buffer) => {
1065
1109
 
1066
1110
  return;
1067
1111
  }
1112
+ case 'picker-agent': {
1113
+ applyTextKey(
1114
+ buf,
1115
+ () => {
1116
+ spawnAgent = AGENT_PICKS[pickerSelected]?.agent ?? 'claude';
1117
+ void openDirPicker();
1118
+ },
1119
+ () => {
1120
+ toBase();
1121
+ },
1122
+ );
1123
+
1124
+ return;
1125
+ }
1068
1126
  case 'picker-dir': {
1069
1127
  applyTextKey(
1070
1128
  buf,
@@ -1090,7 +1148,13 @@ process.stdin.on('data', (buf: Buffer) => {
1090
1148
  renderPicker();
1091
1149
  },
1092
1150
  () => {
1093
- toBase();
1151
+ pickerInput = '';
1152
+ pickerSelected = spawnAgent === 'grok' ? 1 : 0;
1153
+ mode = 'picker-agent';
1154
+
1155
+ stdout.write(ansi.clear);
1156
+
1157
+ renderPicker();
1094
1158
  },
1095
1159
  );
1096
1160
 
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 or grok; 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'],
56
+ description: 'Which agent CLI to spawn; defaults to claude',
57
+ },
53
58
  },
54
59
  required: ['cwd'],
55
60
  additionalProperties: false,
@@ -236,10 +241,17 @@ 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 (rawAgent !== undefined && rawAgent !== 'claude' && rawAgent !== 'grok') {
247
+ throw new Error("agent must be 'claude' or 'grok'");
248
+ }
249
+
239
250
  const ok = await client.sendRequest('session.spawn', {
240
251
  cwd: args['cwd'],
241
252
  ...(typeof args['name'] === 'string' ? { name: args['name'] } : {}),
242
253
  ...(typeof args['prompt'] === 'string' ? { prompt: args['prompt'] } : {}),
254
+ ...(rawAgent === 'claude' || rawAgent === 'grok' ? { agent: rawAgent } : {}),
243
255
  cols: 100,
244
256
  rows: 30,
245
257
  });
@@ -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,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.
package/src/sessions.ts CHANGED
@@ -2,7 +2,8 @@ import { readFileSync, writeFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { spawn } from 'bun-pty';
4
4
  import type { IPty } from 'bun-pty';
5
- import type { AgentAdapter } from './agent-adapter';
5
+ import { toAgentKind } from './agent-adapter';
6
+ import type { AdapterEvent, AgentAdapter, AgentKind } from './agent-adapter';
6
7
  import { collectCleanEnv } from './collect-clean-env';
7
8
  import { socketPath, stateDir, statusFile } from './config';
8
9
  import type { HookEvent } from './hooks';
@@ -23,13 +24,14 @@ export interface SessionDescriptor {
23
24
  readonly unread: boolean;
24
25
  readonly lastMsg: string;
25
26
  readonly lastDetail?: string;
26
- readonly claudeId?: string;
27
+ readonly agentSessionID?: string;
28
+ readonly agent: AgentKind;
27
29
  readonly pinned: boolean;
28
30
  readonly lastAttachedAt: number;
29
31
  readonly repoRoot: string;
30
32
  readonly namedBy: 'user' | 'auto' | 'agent';
31
33
  readonly createdAt: number;
32
- readonly kind: 'pty' | 'jsonl';
34
+ readonly kind: 'pty' | 'headless';
33
35
  readonly alive: boolean;
34
36
  }
35
37
 
@@ -37,13 +39,14 @@ export interface Session {
37
39
  id: string;
38
40
  name: string;
39
41
  cwd: string;
40
- kind: 'pty' | 'jsonl';
42
+ kind: 'pty' | 'headless';
41
43
  pty: IPty | null;
42
44
  state: SessionState;
43
45
  unread: boolean;
44
46
  lastMsg: string;
45
47
  lastDetail?: string;
46
- claudeId?: string;
48
+ agentSessionID?: string;
49
+ agent: AgentKind;
47
50
  transcriptSource?: string;
48
51
  pinned: boolean;
49
52
 
@@ -66,7 +69,8 @@ let counter = 0;
66
69
  export interface FleetEntry {
67
70
  readonly name: string;
68
71
  readonly cwd: string;
69
- readonly claudeId: string;
72
+ readonly agentSessionID: string;
73
+ readonly agent: AgentKind;
70
74
  readonly pinned?: boolean;
71
75
  readonly lastAttachedAt?: number;
72
76
  }
@@ -87,6 +91,29 @@ const jsonFleetStore: FleetStore = {
87
91
  },
88
92
  };
89
93
 
94
+ export function parseFleetEntry(raw: unknown): FleetEntry | undefined {
95
+ if (!isRecord(raw) || typeof raw['name'] !== 'string' || typeof raw['cwd'] !== 'string') {
96
+ return undefined;
97
+ }
98
+
99
+ // Fleet files written before the id key was agent-neutral carry it under
100
+ // its Claude-era name.
101
+ const agentSessionID = raw['agentSessionID'] ?? raw['claudeId'];
102
+
103
+ if (typeof agentSessionID !== 'string') {
104
+ return undefined;
105
+ }
106
+
107
+ return {
108
+ name: raw['name'],
109
+ cwd: raw['cwd'],
110
+ agentSessionID,
111
+ agent: toAgentKind(raw['agent']),
112
+ ...(raw['pinned'] === true ? { pinned: true } : {}),
113
+ ...(typeof raw['lastAttachedAt'] === 'number' ? { lastAttachedAt: raw['lastAttachedAt'] } : {}),
114
+ };
115
+ }
116
+
90
117
  function loadFleet(): FleetEntry[] {
91
118
  try {
92
119
  const parsed: unknown = JSON.parse(readFileSync(fleetFile, 'utf8'));
@@ -95,13 +122,17 @@ function loadFleet(): FleetEntry[] {
95
122
  return [];
96
123
  }
97
124
 
98
- return parsed.filter(
99
- (entry): entry is FleetEntry =>
100
- isRecord(entry) &&
101
- typeof entry['name'] === 'string' &&
102
- typeof entry['cwd'] === 'string' &&
103
- typeof entry['claudeId'] === 'string',
104
- );
125
+ const entries: FleetEntry[] = [];
126
+
127
+ for (const entry of parsed) {
128
+ const parsedEntry = parseFleetEntry(entry);
129
+
130
+ if (parsedEntry !== undefined) {
131
+ entries.push(parsedEntry);
132
+ }
133
+ }
134
+
135
+ return entries;
105
136
  } catch {
106
137
  return [];
107
138
  }
@@ -118,31 +149,51 @@ export class SessionManager {
118
149
 
119
150
  onEvent: (kind: SessionEventKind, s: Session) => void = () => {};
120
151
 
121
- private readonly adapter: AgentAdapter;
152
+ private readonly fallback: AgentAdapter;
153
+
154
+ private readonly adapters: Partial<Readonly<Record<AgentKind, AgentAdapter>>>;
122
155
 
123
156
  private readonly store: FleetStore;
124
157
 
125
158
  private readonly statusPath: string;
126
159
 
127
- constructor(adapter: AgentAdapter, store: FleetStore = jsonFleetStore, statusPath = statusFile) {
128
- this.adapter = adapter;
160
+ constructor(
161
+ fallback: AgentAdapter,
162
+ store: FleetStore = jsonFleetStore,
163
+ statusPath: string | undefined = statusFile,
164
+ adapters: Partial<Readonly<Record<AgentKind, AgentAdapter>>> = {},
165
+ ) {
166
+ this.fallback = fallback;
167
+ this.adapters = adapters;
129
168
  this.store = store;
130
- this.statusPath = statusPath;
169
+ this.statusPath = statusPath ?? statusFile;
170
+ }
171
+
172
+ /**
173
+ * Adapter for this kind, or null when none is registered. Never returns a
174
+ * different kind than the one asked for.
175
+ */
176
+ findAdapter(kind: AgentKind): AgentAdapter | null {
177
+ if (kind === 'claude') {
178
+ return this.adapters.claude ?? this.fallback;
179
+ }
180
+
181
+ return this.adapters[kind] ?? null;
131
182
  }
132
183
 
133
184
  // Hands a live terminal session off to a headless run: the terminal dies,
134
- // the record lives on as a jsonl session and keeps its screen history.
185
+ // the record lives on as a headless session and keeps its screen history.
135
186
  yankHeadless(id: string): Session | null {
136
187
  const s = this.sessions.find((x) => x.id === id);
137
188
 
138
- if (!s || s.pty === null || s.claudeId === undefined) {
189
+ if (!s || s.pty === null || s.agentSessionID === undefined) {
139
190
  return null;
140
191
  }
141
192
 
142
193
  const pty = s.pty;
143
194
 
144
195
  s.pty = null;
145
- s.kind = 'jsonl';
196
+ s.kind = 'headless';
146
197
  s.state = 'running';
147
198
  s.lastMsg = 'ejected to headless';
148
199
 
@@ -161,12 +212,13 @@ export class SessionManager {
161
212
  id: `s${++counter}-${Date.now().toString(36)}`,
162
213
  name: entry.name,
163
214
  cwd: entry.cwd,
164
- kind: 'jsonl',
215
+ kind: 'headless',
165
216
  pty: null,
166
217
  state: 'running',
167
218
  unread: false,
168
219
  lastMsg: 'waiting to restore',
169
- claudeId: entry.claudeId,
220
+ agentSessionID: entry.agentSessionID,
221
+ agent: entry.agent,
170
222
  pinned: entry.pinned ?? false,
171
223
  lastAttachedAt: entry.lastAttachedAt ?? Date.now(),
172
224
  repoRoot: resolveRepoRoot(entry.cwd),
@@ -186,11 +238,17 @@ export class SessionManager {
186
238
  adoptTerminal(id: string, cols: number, rows: number): Session | null {
187
239
  const s = this.sessions.find((x) => x.id === id);
188
240
 
189
- if (!s || s.pty !== null || s.claudeId === undefined) {
241
+ if (!s || s.pty !== null || s.agentSessionID === undefined) {
242
+ return null;
243
+ }
244
+
245
+ const adapter = this.findAdapter(s.agent);
246
+
247
+ if (adapter === null) {
190
248
  return null;
191
249
  }
192
250
 
193
- const plan = this.adapter.planSpawn({ prompt: '', resume: s.claudeId });
251
+ const plan = adapter.planSpawn({ prompt: '', resume: s.agentSessionID });
194
252
 
195
253
  const pty = spawn(plan.bin, plan.args, {
196
254
  name: 'xterm-256color',
@@ -264,7 +322,7 @@ export class SessionManager {
264
322
  updateSurfaceState(id: string, state: SessionState, msg: string) {
265
323
  const s = this.sessions.find((x) => x.id === id);
266
324
 
267
- if (!s || s.kind !== 'jsonl') {
325
+ if (!s || s.kind !== 'headless') {
268
326
  return;
269
327
  }
270
328
 
@@ -314,9 +372,16 @@ export class SessionManager {
314
372
  rows: number,
315
373
  resume: boolean | string = false,
316
374
  namedBy: 'user' | 'auto' = 'auto',
375
+ agent: AgentKind = 'claude',
317
376
  ): Session {
377
+ const adapter = this.findAdapter(agent);
378
+
379
+ if (adapter === null) {
380
+ throw new Error(`no adapter for agent '${agent}'`);
381
+ }
382
+
318
383
  const id = `s${++counter}-${Date.now().toString(36)}`;
319
- const plan = this.adapter.planSpawn({ prompt, resume });
384
+ const plan = adapter.planSpawn({ prompt, resume });
320
385
 
321
386
  const pty = spawn(plan.bin, plan.args, {
322
387
  name: 'xterm-256color',
@@ -341,7 +406,8 @@ export class SessionManager {
341
406
  state: 'running',
342
407
  unread: false,
343
408
  lastMsg: initialMsg,
344
- ...(typeof resume === 'string' ? { claudeId: resume } : {}),
409
+ ...(typeof resume === 'string' ? { agentSessionID: resume } : {}),
410
+ agent,
345
411
  pinned: false,
346
412
  lastAttachedAt: Date.now(),
347
413
  repoRoot: resolveRepoRoot(cwd),
@@ -385,32 +451,46 @@ export class SessionManager {
385
451
  unread: s.unread,
386
452
  lastMsg: s.lastMsg,
387
453
  ...(s.lastDetail === undefined ? {} : { lastDetail: s.lastDetail }),
388
- ...(s.claudeId === undefined ? {} : { claudeId: s.claudeId }),
454
+ ...(s.agentSessionID === undefined ? {} : { agentSessionID: s.agentSessionID }),
455
+ agent: s.agent,
389
456
  pinned: s.pinned,
390
457
  lastAttachedAt: s.lastAttachedAt,
391
458
  repoRoot: s.repoRoot,
392
459
  namedBy: s.namedBy,
393
460
  createdAt: s.createdAt,
394
461
  kind: s.kind,
395
- alive: s.pty !== null || (s.kind === 'jsonl' && s.state !== 'exited'),
462
+ alive: s.pty !== null || (s.kind === 'headless' && s.state !== 'exited'),
396
463
  }));
397
464
  }
398
465
 
399
- applyHook(e: HookEvent) {
466
+ // Returns the normalized event kind so the caller can key lifecycle
467
+ // bookkeeping on it, or null when no session or adapter matches.
468
+ applyHook(e: HookEvent): AdapterEvent['kind'] | null {
400
469
  const s = this.sessions.find((x) => x.id === e.atcId);
401
470
 
471
+ if (!s) {
472
+ return null;
473
+ }
474
+
475
+ const adapter = this.findAdapter(s.agent);
476
+
477
+ if (adapter === null) {
478
+ return null;
479
+ }
480
+
481
+ const ev = adapter.normalizeHook(e);
482
+
402
483
  // Reporters belong to the terminal process; once a session is headless,
403
484
  // late reports from the dying terminal must not clobber its state.
404
- if (!s || s.kind === 'jsonl') {
405
- return;
485
+ if (s.kind === 'headless') {
486
+ return ev.kind;
406
487
  }
407
488
 
408
- const ev = this.adapter.normalizeHook(e);
409
489
  const focused = this.focusedId === s.id;
410
490
  let dirty = false;
411
491
 
412
- if (ev.agentSessionID !== undefined && s.claudeId !== ev.agentSessionID) {
413
- s.claudeId = ev.agentSessionID;
492
+ if (ev.agentSessionID !== undefined && s.agentSessionID !== ev.agentSessionID) {
493
+ s.agentSessionID = ev.agentSessionID;
414
494
 
415
495
  this.writeFleet();
416
496
 
@@ -421,8 +501,11 @@ export class SessionManager {
421
501
  s.lastDetail = ev.detail;
422
502
  }
423
503
 
504
+ if (ev.transcriptSource !== undefined) {
505
+ s.transcriptSource = ev.transcriptSource;
506
+ }
507
+
424
508
  if (ev.nameSource !== undefined) {
425
- s.transcriptSource = ev.nameSource;
426
509
  void this.refreshName(s, ev.nameSource);
427
510
  }
428
511
 
@@ -481,10 +564,18 @@ export class SessionManager {
481
564
  this.onEvent('state', s);
482
565
  this.emitChange();
483
566
  }
567
+
568
+ return ev.kind;
484
569
  }
485
570
 
486
571
  private async refreshName(s: Session, source: string) {
487
- const update = await this.adapter.loadName(source, s.namedBy);
572
+ const adapter = this.findAdapter(s.agent);
573
+
574
+ if (adapter === null) {
575
+ return;
576
+ }
577
+
578
+ const update = await adapter.loadName(source, s.namedBy);
488
579
 
489
580
  if (update === null || update.name === '' || update.name === s.name) {
490
581
  return;
@@ -509,7 +600,7 @@ export class SessionManager {
509
600
  return null;
510
601
  }
511
602
 
512
- return this.adapter.buildResumeCommand(s.cwd, s.claudeId);
603
+ return this.findAdapter(s.agent)?.buildResumeCommand(s.cwd, s.agentSessionID) ?? null;
513
604
  }
514
605
 
515
606
  attach(id: string) {
@@ -605,13 +696,14 @@ export class SessionManager {
605
696
  const fleet: FleetEntry[] = [];
606
697
 
607
698
  for (const s of this.sessions) {
608
- const live = s.pty !== null || (s.kind === 'jsonl' && s.state !== 'exited');
699
+ const live = s.pty !== null || (s.kind === 'headless' && s.state !== 'exited');
609
700
 
610
- if (live && s.claudeId !== undefined) {
701
+ if (live && s.agentSessionID !== undefined) {
611
702
  fleet.push({
612
703
  name: s.name,
613
704
  cwd: s.cwd,
614
- claudeId: s.claudeId,
705
+ agentSessionID: s.agentSessionID,
706
+ agent: s.agent,
615
707
  ...(s.pinned ? { pinned: true } : {}),
616
708
  lastAttachedAt: s.lastAttachedAt,
617
709
  });