@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,178 @@
1
+ import { existsSync, readFileSync } 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 { isRecord } from './report';
15
+
16
+ /**
17
+ * The Codex CLI adapter: spawn arguments, hook payload mapping, resume
18
+ * semantics, and session_index.jsonl name-pulling. Hooks are a user-installed
19
+ * entry in the Codex hook config (`atc codex-hooks` prints it) that the user
20
+ * trusts once in the Codex TUI; atc never writes into the user's Codex config.
21
+ */
22
+ export class CodexAdapter implements AgentAdapter {
23
+ readonly kind = 'codex';
24
+
25
+ // Codex has no headless handoff.
26
+ readonly headlessRunner = null;
27
+
28
+ // Codex's hooks are authoritative; no screen heuristics needed.
29
+ readonly screenDetector = null;
30
+
31
+ private readonly config: Config;
32
+
33
+ constructor(config: Config) {
34
+ this.config = config;
35
+ }
36
+
37
+ planSpawn(opts: SpawnOptions): SpawnPlan {
38
+ return {
39
+ bin: this.config.codexBin,
40
+ args: [
41
+ ...this.config.codexArgs,
42
+
43
+ // Bare resume opens Codex's own session picker; an id resumes that
44
+ // session directly. Both accept a prompt afterwards.
45
+ ...(opts.resume === true ? ['resume'] : []),
46
+ ...(typeof opts.resume === 'string' ? ['resume', opts.resume] : []),
47
+ ...(opts.prompt === '' ? [] : [opts.prompt]),
48
+ ],
49
+ };
50
+ }
51
+
52
+ normalizeHook(e: HookEvent): AdapterEvent {
53
+ const sessionID = e.payload['session_id'];
54
+ const transcript = e.payload['transcript_path'];
55
+
56
+ const base: AdapterEvent = {
57
+ kind: 'heartbeat',
58
+ ...(typeof sessionID === 'string' ? { agentSessionID: sessionID } : {}),
59
+ };
60
+
61
+ const named: AdapterEvent = {
62
+ ...base,
63
+ ...(typeof sessionID === 'string' ? { nameSource: sessionID } : {}),
64
+ ...(typeof transcript === 'string' ? { transcriptSource: transcript } : {}),
65
+ };
66
+
67
+ switch (e.event) {
68
+ case 'SessionStart': {
69
+ return { ...named, kind: 'started' };
70
+ }
71
+ case 'PermissionRequest': {
72
+ const toolName = e.payload['tool_name'];
73
+
74
+ const message =
75
+ typeof toolName === 'string' && toolName !== ''
76
+ ? `waiting for approval: ${toolName}`
77
+ : 'waiting for approval';
78
+
79
+ return { ...base, kind: 'needs-input', message, detail: message };
80
+ }
81
+ case 'UserPromptSubmit': {
82
+ const prompt = e.payload['prompt'];
83
+ const preview = typeof prompt === 'string' ? prompt.slice(0, 80) : '';
84
+
85
+ return {
86
+ ...named,
87
+ kind: 'prompt-submitted',
88
+ ...(preview === '' ? {} : { message: preview, detail: truncateDetail(preview) }),
89
+ };
90
+ }
91
+ case 'Stop': {
92
+ const lastMessage = e.payload['last_assistant_message'];
93
+
94
+ return {
95
+ ...named,
96
+ kind: 'turn-done',
97
+ ...(typeof lastMessage === 'string' && lastMessage !== ''
98
+ ? { detail: truncateDetail(lastMessage) }
99
+ : {}),
100
+ };
101
+ }
102
+ case 'SessionEnd': {
103
+ return { ...base, kind: 'ended' };
104
+ }
105
+ default: {
106
+ return base;
107
+ }
108
+ }
109
+ }
110
+
111
+ // Codex records session titles in session_index.jsonl; the index does not
112
+ // say whether a title was set by /rename or auto-generated, so a title
113
+ // never overrides a user-typed name.
114
+ loadName(source: string, namedBy: 'user' | 'auto' | 'agent'): Promise<NameUpdate | null> {
115
+ if (namedBy === 'user') {
116
+ return Promise.resolve(null);
117
+ }
118
+
119
+ const index = join(resolveCodexHome(), 'session_index.jsonl');
120
+ let text: string;
121
+
122
+ try {
123
+ text = readFileSync(index, 'utf8');
124
+ } catch {
125
+ return Promise.resolve(null);
126
+ }
127
+
128
+ let name: string | undefined;
129
+
130
+ for (const line of text.split('\n')) {
131
+ if (line.trim() === '' || !line.includes(source)) {
132
+ continue;
133
+ }
134
+
135
+ try {
136
+ const parsed: unknown = JSON.parse(line);
137
+
138
+ if (
139
+ isRecord(parsed) &&
140
+ parsed['id'] === source &&
141
+ typeof parsed['thread_name'] === 'string'
142
+ ) {
143
+ name = parsed['thread_name'];
144
+ }
145
+ } catch {}
146
+ }
147
+
148
+ const update = name === undefined || name === '' ? null : { name };
149
+
150
+ return Promise.resolve(update);
151
+ }
152
+
153
+ canResume(session: ResumeCheck): boolean {
154
+ if (session.transcriptSource === undefined) {
155
+ return true;
156
+ }
157
+
158
+ return existsSync(session.transcriptSource);
159
+ }
160
+
161
+ // Shell command that re-opens this session outside atc (or anywhere).
162
+ buildResumeCommand(cwd: string, agentSessionID: string | undefined): string | null {
163
+ const resume = agentSessionID === undefined ? 'codex resume' : `codex resume ${agentSessionID}`;
164
+ const quoted = cwd.replaceAll("'", String.raw`'\''`);
165
+
166
+ return `cd '${quoted}' && ${resume}`;
167
+ }
168
+ }
169
+
170
+ function resolveCodexHome(): string {
171
+ const home = process.env['CODEX_HOME'];
172
+
173
+ return home !== undefined && home !== '' ? home : join(homedir(), '.codex');
174
+ }
175
+
176
+ function truncateDetail(text: string): string {
177
+ return text.length <= 600 ? text : `${text.slice(0, 599)}…`;
178
+ }
@@ -1,8 +1,8 @@
1
1
  /**
2
- * The process environment with any enclosing Claude Code session scrubbed
3
- * out. A claude spawned with those variables intact behaves as a child of
4
- * the enclosing session — its transcript lands inside the parent's instead
5
- * of its own project directory, which breaks resume and handoff.
2
+ * The process environment with any enclosing Claude or Grok session
3
+ * scrubbed out. A child that inherits those variables behaves as part of
4
+ * the parent session — Claude transcripts nest, and Grok joins the
5
+ * parent's in-process dashboard — which breaks resume and isolation.
6
6
  */
7
7
  export function collectCleanEnv(
8
8
  extra: Readonly<Record<string, string>> = {},
@@ -10,7 +10,7 @@ export function collectCleanEnv(
10
10
  const env: Record<string, string> = {};
11
11
 
12
12
  for (const [key, value] of Object.entries(process.env)) {
13
- if (value === undefined || key === 'CLAUDECODE' || key.startsWith('CLAUDE_CODE_')) {
13
+ if (value === undefined || isParentSessionKey(key)) {
14
14
  continue;
15
15
  }
16
16
 
@@ -19,3 +19,33 @@ export function collectCleanEnv(
19
19
 
20
20
  return { ...env, ...extra };
21
21
  }
22
+
23
+ const GROK_PARENT_KEYS = new Set([
24
+ 'GROK_SESSION_ID',
25
+ 'GROK_LEADER_SOCKET',
26
+ 'GROK_LEADER_LOG',
27
+ 'GROK_HOOK_EVENT',
28
+ 'GROK_HOOK_NAME',
29
+ 'GROK_WORKSPACE_ROOT',
30
+ 'GROK_EVENT',
31
+ 'GROK_MESSAGE',
32
+ 'GROK_PLUGIN_ROOT',
33
+ 'GROK_PLUGIN_DATA',
34
+ 'GROK_SESSION_RESTORED',
35
+ 'GROK_AGENT_METADATA',
36
+ 'GROK_INIT_STATE_MARKER__',
37
+ 'GROK_BASH_STATE_START__',
38
+ 'GROK_BASH_STATE_END__',
39
+ 'GROK_ZSH_STATE_START__',
40
+ 'GROK_ZSH_STATE_END__',
41
+ 'GROK_INSIDE_BWRAP',
42
+ ]);
43
+
44
+ function isParentSessionKey(key: string): boolean {
45
+ return (
46
+ key === 'CLAUDECODE' ||
47
+ key.startsWith('CLAUDE_CODE_') ||
48
+ key.startsWith('CODEX_SANDBOX') ||
49
+ GROK_PARENT_KEYS.has(key)
50
+ );
51
+ }
package/src/config.ts CHANGED
@@ -6,6 +6,10 @@ import { isRecord } from './report';
6
6
  export interface Config {
7
7
  claudeBin: string;
8
8
  claudeArgs: string[];
9
+ grokBin: string;
10
+ grokArgs: string[];
11
+ codexBin: string;
12
+ codexArgs: string[];
9
13
  leader: LeaderKey;
10
14
  }
11
15
 
@@ -17,6 +21,10 @@ interface LeaderKey {
17
21
  const DEFAULTS: Config = {
18
22
  claudeBin: 'claude',
19
23
  claudeArgs: [],
24
+ grokBin: 'grok',
25
+ grokArgs: [],
26
+ codexBin: 'codex',
27
+ codexArgs: [],
20
28
  leader: { code: 0, label: '^Space' },
21
29
  };
22
30
 
@@ -57,11 +65,24 @@ export function loadConfig(): Config {
57
65
  ? parsed['claudeArgs'].filter((a): a is string => typeof a === 'string')
58
66
  : DEFAULTS.claudeArgs;
59
67
 
68
+ const grokBin = typeof parsed['grokBin'] === 'string' ? parsed['grokBin'] : DEFAULTS.grokBin;
69
+
70
+ const grokArgs = Array.isArray(parsed['grokArgs'])
71
+ ? parsed['grokArgs'].filter((a): a is string => typeof a === 'string')
72
+ : DEFAULTS.grokArgs;
73
+
74
+ const codexBin =
75
+ typeof parsed['codexBin'] === 'string' ? parsed['codexBin'] : DEFAULTS.codexBin;
76
+
77
+ const codexArgs = Array.isArray(parsed['codexArgs'])
78
+ ? parsed['codexArgs'].filter((a): a is string => typeof a === 'string')
79
+ : DEFAULTS.codexArgs;
80
+
60
81
  const leader =
61
82
  (typeof parsed['leader'] === 'string' ? decodeLeader(parsed['leader']) : null) ??
62
83
  DEFAULTS.leader;
63
84
 
64
- return { claudeBin, claudeArgs, leader };
85
+ return { claudeBin, claudeArgs, grokBin, grokArgs, codexBin, codexArgs, leader };
65
86
  } catch {
66
87
  return { ...DEFAULTS };
67
88
  }
@@ -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,6 +16,7 @@ interface SpawnParams {
15
16
  readonly rows: number;
16
17
  readonly resume: boolean | string;
17
18
  readonly namedBy: 'user' | 'auto';
19
+ readonly agent: AgentKind;
18
20
  }
19
21
 
20
22
  export interface DaemonContext {
@@ -22,6 +24,8 @@ export interface DaemonContext {
22
24
  readonly collectSessions: () => SessionDescriptor[];
23
25
  readonly collectSpawnDirs: () => string[];
24
26
  readonly collectFleet: () => FleetEntry[];
27
+ readonly loadLastUsedAgent: () => AgentKind;
28
+ readonly findAdapter: (kind: AgentKind) => AgentAdapter | null;
25
29
  readonly spawnSession: (p: SpawnParams) => SessionDescriptor;
26
30
  readonly killSession: (id: string) => boolean;
27
31
  readonly updateSession: (id: string, name?: string, pinned?: boolean) => boolean;
@@ -269,7 +273,7 @@ export class DaemonConnection {
269
273
  if (result === 'ok') {
270
274
  this.sendOk(req.id, {});
271
275
  } else if (result === 'unsupported') {
272
- 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");
273
277
  } else if (result === 'no_transcript') {
274
278
  this.sendErr(
275
279
  req.id,
@@ -369,6 +373,27 @@ export class DaemonConnection {
369
373
  resume = rawResume;
370
374
  }
371
375
 
376
+ const rawAgent = req.p?.['agent'];
377
+
378
+ if (
379
+ rawAgent !== undefined &&
380
+ rawAgent !== 'claude' &&
381
+ rawAgent !== 'grok' &&
382
+ rawAgent !== 'codex'
383
+ ) {
384
+ this.sendErr(req.id, 'bad_args', "session.spawn agent must be 'claude', 'grok', or 'codex'");
385
+
386
+ return;
387
+ }
388
+
389
+ const agent: AgentKind = rawAgent === 'grok' || rawAgent === 'codex' ? rawAgent : 'claude';
390
+
391
+ if (this.ctx.findAdapter(agent) === null) {
392
+ this.sendErr(req.id, 'unsupported', `no adapter for agent '${agent}'`);
393
+
394
+ return;
395
+ }
396
+
372
397
  const session = this.ctx.spawnSession({
373
398
  cwd,
374
399
  name: name === '' ? basename(cwd) : name,
@@ -377,6 +402,7 @@ export class DaemonConnection {
377
402
  rows: typeof req.p?.['rows'] === 'number' ? req.p['rows'] : 24,
378
403
  resume,
379
404
  namedBy: name === '' ? 'auto' : 'user',
405
+ agent,
380
406
  });
381
407
 
382
408
  this.sendOk(req.id, { session });
@@ -517,6 +543,7 @@ export class DaemonConnection {
517
543
  this.sendOk(req.id, {
518
544
  daemon: this.ctx.build,
519
545
  limits: { maxLine: MAX_LINE, maxChunk: MAX_CHUNK },
546
+ lastUsedAgent: this.ctx.loadLastUsedAgent(),
520
547
  });
521
548
 
522
549
  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,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,17 @@
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
+ if (agent === 'grok') {
9
+ return 'g';
10
+ }
11
+
12
+ if (agent === 'codex') {
13
+ return 'x';
14
+ }
15
+
16
+ return ' ';
17
+ }