@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.
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
  });
@@ -1,7 +1,9 @@
1
1
  import { Database } from 'bun:sqlite';
2
2
  import { existsSync, readFileSync } from 'node:fs';
3
+ import { toAgentKind } from './agent-adapter';
4
+ import type { AgentKind } from './agent-adapter';
3
5
  import type { HookEvent } from './hooks';
4
- import { isRecord } from './report';
6
+ import { parseFleetEntry } from './sessions';
5
7
  import type { FleetEntry } from './sessions';
6
8
 
7
9
  /**
@@ -21,11 +23,12 @@ export class StateStore {
21
23
 
22
24
  this.db.run(`
23
25
  CREATE TABLE IF NOT EXISTS fleet (
24
- claude_id TEXT PRIMARY KEY,
26
+ agent_session_id TEXT PRIMARY KEY,
25
27
  name TEXT NOT NULL,
26
28
  cwd TEXT NOT NULL,
27
29
  pinned INTEGER NOT NULL DEFAULT 0,
28
- last_attached INTEGER
30
+ last_attached INTEGER,
31
+ agent TEXT NOT NULL DEFAULT 'claude'
29
32
  );
30
33
  CREATE TABLE IF NOT EXISTS events (
31
34
  id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -39,10 +42,14 @@ export class StateStore {
39
42
  cwd TEXT PRIMARY KEY,
40
43
  last_spawn INTEGER NOT NULL
41
44
  );
45
+ CREATE TABLE IF NOT EXISTS prefs (
46
+ key TEXT PRIMARY KEY,
47
+ value TEXT NOT NULL
48
+ );
42
49
  `);
43
50
 
44
- // Stores created before pinning and attach recency existed lack the
45
- // columns; the create above only applies to fresh databases.
51
+ // Stores created before pinning, attach recency, or agent kind existed
52
+ // lack the columns; the create above only applies to fresh databases.
46
53
  const fleetColumns = new Set(
47
54
  this.db
48
55
  .query<{ name: string }, []>('PRAGMA table_info(fleet)')
@@ -50,6 +57,12 @@ export class StateStore {
50
57
  .map((c) => c.name),
51
58
  );
52
59
 
60
+ // Stores created before the id column was agent-neutral carry it under
61
+ // its Claude-era name.
62
+ if (fleetColumns.has('claude_id')) {
63
+ this.db.run('ALTER TABLE fleet RENAME COLUMN claude_id TO agent_session_id');
64
+ }
65
+
53
66
  if (!fleetColumns.has('pinned')) {
54
67
  this.db.run('ALTER TABLE fleet ADD COLUMN pinned INTEGER NOT NULL DEFAULT 0');
55
68
  }
@@ -58,6 +71,10 @@ export class StateStore {
58
71
  this.db.run('ALTER TABLE fleet ADD COLUMN last_attached INTEGER');
59
72
  }
60
73
 
74
+ if (!fleetColumns.has('agent')) {
75
+ this.db.run("ALTER TABLE fleet ADD COLUMN agent TEXT NOT NULL DEFAULT 'claude'");
76
+ }
77
+
61
78
  if (legacyFleetPath !== undefined) {
62
79
  this.adoptLegacyFleet(legacyFleetPath);
63
80
  }
@@ -67,23 +84,25 @@ export class StateStore {
67
84
  const rows = this.db
68
85
  .query<
69
86
  {
70
- claude_id: string;
87
+ agent_session_id: string;
71
88
  name: string;
72
89
  cwd: string;
73
90
  pinned: number;
74
91
  last_attached: number | null;
92
+ agent: string | null;
75
93
  },
76
94
  []
77
- >('SELECT claude_id, name, cwd, pinned, last_attached FROM fleet')
95
+ >('SELECT agent_session_id, name, cwd, pinned, last_attached, agent FROM fleet')
78
96
  .all();
79
97
 
80
98
  const entries: FleetEntry[] = [];
81
99
 
82
100
  for (const row of rows) {
83
101
  entries.push({
84
- claudeId: row.claude_id,
102
+ agentSessionID: row.agent_session_id,
85
103
  name: row.name,
86
104
  cwd: row.cwd,
105
+ agent: toAgentKind(row.agent),
87
106
  ...(row.pinned === 0 ? {} : { pinned: true }),
88
107
  ...(row.last_attached === null ? {} : { lastAttachedAt: row.last_attached }),
89
108
  });
@@ -109,13 +128,20 @@ export class StateStore {
109
128
  this.db.run('DELETE FROM fleet');
110
129
 
111
130
  const insert = this.db.query(
112
- 'INSERT OR REPLACE INTO fleet (claude_id, name, cwd, pinned, last_attached) VALUES (?1, ?2, ?3, ?4, ?5)',
131
+ 'INSERT OR REPLACE INTO fleet (agent_session_id, name, cwd, pinned, last_attached, agent) VALUES (?1, ?2, ?3, ?4, ?5, ?6)',
113
132
  );
114
133
 
115
134
  for (const entry of all) {
116
135
  const pinned = entry.pinned === true ? 1 : 0;
117
136
 
118
- insert.run(entry.claudeId, entry.name, entry.cwd, pinned, entry.lastAttachedAt ?? null);
137
+ insert.run(
138
+ entry.agentSessionID,
139
+ entry.name,
140
+ entry.cwd,
141
+ pinned,
142
+ entry.lastAttachedAt ?? null,
143
+ entry.agent,
144
+ );
119
145
  }
120
146
  });
121
147
 
@@ -124,7 +150,7 @@ export class StateStore {
124
150
 
125
151
  recordEvent(e: HookEvent): void {
126
152
  const rawMessage = e.payload['message'];
127
- const rawSessionID = e.payload['session_id'];
153
+ const rawSessionID = e.payload['session_id'] ?? e.payload['sessionId'];
128
154
  const message = typeof rawMessage === 'string' ? rawMessage : null;
129
155
  const sessionID = typeof rawSessionID === 'string' ? rawSessionID : null;
130
156
 
@@ -151,6 +177,22 @@ export class StateStore {
151
177
  return rows.map((row) => row.cwd);
152
178
  }
153
179
 
180
+ loadLastUsedAgent(): AgentKind {
181
+ const row = this.db
182
+ .query<{ value: string }, []>("SELECT value FROM prefs WHERE key = 'last_used_agent'")
183
+ .get();
184
+
185
+ return toAgentKind(row?.value);
186
+ }
187
+
188
+ writeLastUsedAgent(agent: AgentKind): void {
189
+ this.db
190
+ .query(
191
+ 'INSERT INTO prefs (key, value) VALUES (?1, ?2) ON CONFLICT(key) DO UPDATE SET value = ?2',
192
+ )
193
+ .run('last_used_agent', agent);
194
+ }
195
+
154
196
  stop(): void {
155
197
  this.db.close();
156
198
  }
@@ -169,13 +211,15 @@ export class StateStore {
169
211
  return;
170
212
  }
171
213
 
172
- const entries = parsed.filter(
173
- (entry): entry is FleetEntry =>
174
- isRecord(entry) &&
175
- typeof entry['name'] === 'string' &&
176
- typeof entry['cwd'] === 'string' &&
177
- typeof entry['claudeId'] === 'string',
178
- );
214
+ const entries: FleetEntry[] = [];
215
+
216
+ for (const entry of parsed) {
217
+ const parsedEntry = parseFleetEntry(entry);
218
+
219
+ if (parsedEntry !== undefined) {
220
+ entries.push(parsedEntry);
221
+ }
222
+ }
179
223
 
180
224
  this.writeFleet(entries);
181
225
  } catch {}
package/src/ui.ts CHANGED
@@ -1,4 +1,6 @@
1
+ import type { AgentKind } from './agent-adapter';
1
2
  import { formatDir } from './dirs';
3
+ import { formatOverlayAgentMark } from './format-overlay-agent-mark';
2
4
  import { RESET_INPUT_MODES } from './reset-input-modes';
3
5
  import { PINNED_GROUP_KEY } from './sessions';
4
6
  import type { SessionState } from './sessions';
@@ -159,8 +161,9 @@ function dimRow(width: number, text: string): Row {
159
161
  // Selection-dependent hints need the liveness facts of each row.
160
162
  export interface OverlaySessionView extends SessionView {
161
163
  readonly alive: boolean;
162
- readonly kind: 'pty' | 'jsonl';
164
+ readonly kind: 'pty' | 'headless';
163
165
  readonly resumable: boolean;
166
+ readonly agent: AgentKind;
164
167
  readonly pinned: boolean;
165
168
  readonly repoRoot: string;
166
169
  }
@@ -202,14 +205,18 @@ export function drawOverlay(view: OverlayView) {
202
205
  const name = truncate(s.name, 16).padEnd(16);
203
206
  const state = STATE_LABEL[s.state].padEnd(9);
204
207
  const dir = view.grouped ? '' : ` ${truncate(formatDir(s.cwd), 18).padEnd(18)}`;
205
- const msgWidth = Math.max(4, width - 4 - 3 - 17 - 10 - (view.grouped ? 0 : 19));
208
+ const msgWidth = Math.max(4, width - 4 - 4 - 17 - 10 - (view.grouped ? 0 : 19));
206
209
  const msg = truncate(s.lastMsg, msgWidth).padEnd(msgWidth);
207
210
  const unread = s.unread ? `${ESC}[1;33m!${ESC}[0m` : ' ';
208
211
  const pin = s.pinned ? `${ESC}[93m⋆${ESC}[0m` : ' ';
212
+ const mark = formatOverlayAgentMark(s.agent);
213
+ const styledMark = mark === 'g' ? `${ESC}[90m${mark}${ESC}[0m` : mark;
209
214
  const body = `${name} ${state}${dir} ${msg}`;
210
215
  const styledBody = sel ? `${ESC}[7m${body}${ESC}[0m` : body;
211
216
 
212
- rowsList.push(boxRow(width, `${GLYPH[s.state]}${unread}${pin}${styledBody}`, 3 + body.length));
217
+ rowsList.push(
218
+ boxRow(width, `${GLYPH[s.state]}${unread}${pin}${styledMark}${styledBody}`, 4 + body.length),
219
+ );
213
220
  }
214
221
 
215
222
  rowsList.push(boxDivider(width));
@@ -245,15 +252,15 @@ export function drawOverlay(view: OverlayView) {
245
252
  const GLOBAL_HINT = 'g groups · n new · ? keys';
246
253
 
247
254
  // Only the actions valid for the selected row appear; the full reference
248
- // lives behind ?.
249
- function buildOverlayHint(s: OverlaySessionView | undefined): string {
255
+ // lives behind ?. Grok has no headless handoff, so H is omitted there.
256
+ export function buildOverlayHint(s: OverlaySessionView | undefined): string {
250
257
  if (s === undefined) {
251
258
  return GLOBAL_HINT;
252
259
  }
253
260
 
254
261
  const actions: string[] = [];
255
262
 
256
- if (s.kind === 'jsonl' && s.alive) {
263
+ if (s.kind === 'headless' && s.alive) {
257
264
  actions.push('P reattach', 'K kill');
258
265
  } else if (s.alive) {
259
266
  actions.push('⏎ attach');
@@ -262,7 +269,11 @@ function buildOverlayHint(s: OverlaySessionView | undefined): string {
262
269
  actions.push('a ack');
263
270
  }
264
271
 
265
- actions.push('H headless', 'y yank', 'Y eject', 'K kill');
272
+ if (s.agent === 'claude') {
273
+ actions.push('H headless');
274
+ }
275
+
276
+ actions.push('y yank', 'Y eject', 'K kill');
266
277
  } else {
267
278
  if (s.resumable) {
268
279
  actions.push('P revive', 'y yank');
@@ -285,13 +296,14 @@ export function drawHelp() {
285
296
  '⏎ attach the selected session',
286
297
  '⇥ attach the most urgent needs-you, else latest done',
287
298
  'a ack its notification without attaching',
288
- 'H eject to a headless run',
299
+ 'H eject to a headless run (Claude only)',
289
300
  'P revive a dead or headless session',
290
301
  'y yank the resume command to the clipboard',
291
302
  'Y yank the resume command, then kill here',
292
303
  'K kill (K again on a dead session forgets it)',
293
304
  'p pin or unpin — pinned sessions stay on top',
294
305
  'g toggle grouping by repository',
306
+ ' Grok rows show a dim g after the pin mark',
295
307
  'n new session',
296
308
  'r adopt an external session',
297
309
  '/ filter · ↑↓/jk move · q quit',
@@ -359,10 +371,10 @@ export function drawHome(fleetCount = 0, leaderLabel = '^Space') {
359
371
  out(ansi.clear + ansi.hideCursor);
360
372
 
361
373
  const msgs = [
362
- 'atc — control tower for claude sessions',
374
+ 'atc — control tower for Claude and Grok sessions',
363
375
  '',
364
376
  'n spawn a session',
365
- 'r adopt an existing session (claude --resume)',
377
+ 'r adopt an existing session',
366
378
  ...(fleetCount > 0 ? [`R restore last fleet (${fleetCount} sessions)`] : []),
367
379
  `${leaderLabel.padEnd(7)} session list`,
368
380
  'q quit',