@zgeoff/atc 0.1.8 → 0.1.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/sessions.ts CHANGED
@@ -2,11 +2,13 @@ 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';
9
10
  import { isRecord } from './report';
11
+ import { resolveRepoRoot } from './resolve-repo-root';
10
12
 
11
13
  export type SessionState = 'running' | 'needs_you' | 'done' | 'exited';
12
14
 
@@ -22,11 +24,14 @@ export interface SessionDescriptor {
22
24
  readonly unread: boolean;
23
25
  readonly lastMsg: string;
24
26
  readonly lastDetail?: string;
25
- readonly claudeId?: string;
26
- readonly group?: string;
27
+ readonly agentSessionID?: string;
28
+ readonly agent: AgentKind;
29
+ readonly pinned: boolean;
30
+ readonly lastAttachedAt: number;
31
+ readonly repoRoot: string;
27
32
  readonly namedBy: 'user' | 'auto' | 'agent';
28
33
  readonly createdAt: number;
29
- readonly kind: 'pty' | 'jsonl';
34
+ readonly kind: 'pty' | 'headless';
30
35
  readonly alive: boolean;
31
36
  }
32
37
 
@@ -34,15 +39,24 @@ export interface Session {
34
39
  id: string;
35
40
  name: string;
36
41
  cwd: string;
37
- kind: 'pty' | 'jsonl';
42
+ kind: 'pty' | 'headless';
38
43
  pty: IPty | null;
39
44
  state: SessionState;
40
45
  unread: boolean;
41
46
  lastMsg: string;
42
47
  lastDetail?: string;
43
- claudeId?: string;
48
+ agentSessionID?: string;
49
+ agent: AgentKind;
44
50
  transcriptSource?: string;
45
- group?: string;
51
+ pinned: boolean;
52
+
53
+ // when the operator last attached, so lists can lead with the sessions
54
+ // they were just working in; starts at creation time.
55
+ lastAttachedAt: number;
56
+
57
+ // the repository this session's directory belongs to (the directory
58
+ // itself outside any repository), for clustering in the overlay.
59
+ repoRoot: string;
46
60
 
47
61
  // who last named this session: the agent's own rename beats everything, a
48
62
  // user-typed spawn name beats auto-summaries.
@@ -55,8 +69,10 @@ let counter = 0;
55
69
  export interface FleetEntry {
56
70
  readonly name: string;
57
71
  readonly cwd: string;
58
- readonly claudeId: string;
59
- readonly group?: string;
72
+ readonly agentSessionID: string;
73
+ readonly agent: AgentKind;
74
+ readonly pinned?: boolean;
75
+ readonly lastAttachedAt?: number;
60
76
  }
61
77
 
62
78
  const fleetFile = join(stateDir, 'fleet.json');
@@ -75,6 +91,29 @@ const jsonFleetStore: FleetStore = {
75
91
  },
76
92
  };
77
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
+
78
117
  function loadFleet(): FleetEntry[] {
79
118
  try {
80
119
  const parsed: unknown = JSON.parse(readFileSync(fleetFile, 'utf8'));
@@ -83,13 +122,17 @@ function loadFleet(): FleetEntry[] {
83
122
  return [];
84
123
  }
85
124
 
86
- return parsed.filter(
87
- (entry): entry is FleetEntry =>
88
- isRecord(entry) &&
89
- typeof entry['name'] === 'string' &&
90
- typeof entry['cwd'] === 'string' &&
91
- typeof entry['claudeId'] === 'string',
92
- );
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;
93
136
  } catch {
94
137
  return [];
95
138
  }
@@ -106,31 +149,51 @@ export class SessionManager {
106
149
 
107
150
  onEvent: (kind: SessionEventKind, s: Session) => void = () => {};
108
151
 
109
- private readonly adapter: AgentAdapter;
152
+ private readonly fallback: AgentAdapter;
153
+
154
+ private readonly adapters: Partial<Readonly<Record<AgentKind, AgentAdapter>>>;
110
155
 
111
156
  private readonly store: FleetStore;
112
157
 
113
158
  private readonly statusPath: string;
114
159
 
115
- constructor(adapter: AgentAdapter, store: FleetStore = jsonFleetStore, statusPath = statusFile) {
116
- 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;
117
168
  this.store = store;
118
- 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;
119
182
  }
120
183
 
121
184
  // Hands a live terminal session off to a headless run: the terminal dies,
122
- // 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.
123
186
  yankHeadless(id: string): Session | null {
124
187
  const s = this.sessions.find((x) => x.id === id);
125
188
 
126
- if (!s || s.pty === null || s.claudeId === undefined) {
189
+ if (!s || s.pty === null || s.agentSessionID === undefined) {
127
190
  return null;
128
191
  }
129
192
 
130
193
  const pty = s.pty;
131
194
 
132
195
  s.pty = null;
133
- s.kind = 'jsonl';
196
+ s.kind = 'headless';
134
197
  s.state = 'running';
135
198
  s.lastMsg = 'ejected to headless';
136
199
 
@@ -149,13 +212,16 @@ export class SessionManager {
149
212
  id: `s${++counter}-${Date.now().toString(36)}`,
150
213
  name: entry.name,
151
214
  cwd: entry.cwd,
152
- kind: 'jsonl',
215
+ kind: 'headless',
153
216
  pty: null,
154
217
  state: 'running',
155
218
  unread: false,
156
219
  lastMsg: 'waiting to restore',
157
- claudeId: entry.claudeId,
158
- ...(entry.group === undefined ? {} : { group: entry.group }),
220
+ agentSessionID: entry.agentSessionID,
221
+ agent: entry.agent,
222
+ pinned: entry.pinned ?? false,
223
+ lastAttachedAt: entry.lastAttachedAt ?? Date.now(),
224
+ repoRoot: resolveRepoRoot(entry.cwd),
159
225
  namedBy: 'auto',
160
226
  createdAt: Date.now(),
161
227
  };
@@ -172,11 +238,17 @@ export class SessionManager {
172
238
  adoptTerminal(id: string, cols: number, rows: number): Session | null {
173
239
  const s = this.sessions.find((x) => x.id === id);
174
240
 
175
- if (!s || s.pty !== null || s.claudeId === undefined) {
241
+ if (!s || s.pty !== null || s.agentSessionID === undefined) {
176
242
  return null;
177
243
  }
178
244
 
179
- const plan = this.adapter.planSpawn({ prompt: '', resume: s.claudeId });
245
+ const adapter = this.findAdapter(s.agent);
246
+
247
+ if (adapter === null) {
248
+ return null;
249
+ }
250
+
251
+ const plan = adapter.planSpawn({ prompt: '', resume: s.agentSessionID });
180
252
 
181
253
  const pty = spawn(plan.bin, plan.args, {
182
254
  name: 'xterm-256color',
@@ -215,13 +287,12 @@ export class SessionManager {
215
287
  return s;
216
288
  }
217
289
 
218
- // Reports a headless run's lifecycle into the session state machine.
219
290
  /**
220
- * Renames and/or regroups a session on a caller's behalf. A rename lands
221
- * at user strength, so auto-summaries stop overwriting it while an
222
- * in-session rename still wins. An empty group clears the grouping.
291
+ * Renames and/or pins a session on a caller's behalf. A rename lands at
292
+ * user strength, so auto-summaries stop overwriting it while an
293
+ * in-session rename still wins. Pinned sessions lead every list.
223
294
  */
224
- updateSession(id: string, name?: string, group?: string): boolean {
295
+ updateSession(id: string, name?: string, pinned?: boolean): boolean {
225
296
  const s = this.sessions.find((x) => x.id === id);
226
297
 
227
298
  if (s === undefined) {
@@ -235,12 +306,8 @@ export class SessionManager {
235
306
  this.onEvent('renamed', s);
236
307
  }
237
308
 
238
- if (group !== undefined) {
239
- if (group === '') {
240
- delete s.group;
241
- } else {
242
- s.group = group;
243
- }
309
+ if (pinned !== undefined && pinned !== s.pinned) {
310
+ s.pinned = pinned;
244
311
 
245
312
  this.onEvent('state', s);
246
313
  }
@@ -255,7 +322,7 @@ export class SessionManager {
255
322
  updateSurfaceState(id: string, state: SessionState, msg: string) {
256
323
  const s = this.sessions.find((x) => x.id === id);
257
324
 
258
- if (!s || s.kind !== 'jsonl') {
325
+ if (!s || s.kind !== 'headless') {
259
326
  return;
260
327
  }
261
328
 
@@ -305,10 +372,16 @@ export class SessionManager {
305
372
  rows: number,
306
373
  resume: boolean | string = false,
307
374
  namedBy: 'user' | 'auto' = 'auto',
308
- group?: string,
375
+ agent: AgentKind = 'claude',
309
376
  ): Session {
377
+ const adapter = this.findAdapter(agent);
378
+
379
+ if (adapter === null) {
380
+ throw new Error(`no adapter for agent '${agent}'`);
381
+ }
382
+
310
383
  const id = `s${++counter}-${Date.now().toString(36)}`;
311
- const plan = this.adapter.planSpawn({ prompt, resume });
384
+ const plan = adapter.planSpawn({ prompt, resume });
312
385
 
313
386
  const pty = spawn(plan.bin, plan.args, {
314
387
  name: 'xterm-256color',
@@ -333,8 +406,11 @@ export class SessionManager {
333
406
  state: 'running',
334
407
  unread: false,
335
408
  lastMsg: initialMsg,
336
- ...(typeof resume === 'string' ? { claudeId: resume } : {}),
337
- ...(group === undefined ? {} : { group }),
409
+ ...(typeof resume === 'string' ? { agentSessionID: resume } : {}),
410
+ agent,
411
+ pinned: false,
412
+ lastAttachedAt: Date.now(),
413
+ repoRoot: resolveRepoRoot(cwd),
338
414
  namedBy,
339
415
  createdAt: Date.now(),
340
416
  };
@@ -375,30 +451,46 @@ export class SessionManager {
375
451
  unread: s.unread,
376
452
  lastMsg: s.lastMsg,
377
453
  ...(s.lastDetail === undefined ? {} : { lastDetail: s.lastDetail }),
378
- ...(s.claudeId === undefined ? {} : { claudeId: s.claudeId }),
379
- ...(s.group === undefined ? {} : { group: s.group }),
454
+ ...(s.agentSessionID === undefined ? {} : { agentSessionID: s.agentSessionID }),
455
+ agent: s.agent,
456
+ pinned: s.pinned,
457
+ lastAttachedAt: s.lastAttachedAt,
458
+ repoRoot: s.repoRoot,
380
459
  namedBy: s.namedBy,
381
460
  createdAt: s.createdAt,
382
461
  kind: s.kind,
383
- alive: s.pty !== null || (s.kind === 'jsonl' && s.state !== 'exited'),
462
+ alive: s.pty !== null || (s.kind === 'headless' && s.state !== 'exited'),
384
463
  }));
385
464
  }
386
465
 
387
- 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 {
388
469
  const s = this.sessions.find((x) => x.id === e.atcId);
389
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
+
390
483
  // Reporters belong to the terminal process; once a session is headless,
391
484
  // late reports from the dying terminal must not clobber its state.
392
- if (!s || s.kind === 'jsonl') {
393
- return;
485
+ if (s.kind === 'headless') {
486
+ return ev.kind;
394
487
  }
395
488
 
396
- const ev = this.adapter.normalizeHook(e);
397
489
  const focused = this.focusedId === s.id;
398
490
  let dirty = false;
399
491
 
400
- if (ev.agentSessionID !== undefined && s.claudeId !== ev.agentSessionID) {
401
- s.claudeId = ev.agentSessionID;
492
+ if (ev.agentSessionID !== undefined && s.agentSessionID !== ev.agentSessionID) {
493
+ s.agentSessionID = ev.agentSessionID;
402
494
 
403
495
  this.writeFleet();
404
496
 
@@ -409,8 +501,11 @@ export class SessionManager {
409
501
  s.lastDetail = ev.detail;
410
502
  }
411
503
 
504
+ if (ev.transcriptSource !== undefined) {
505
+ s.transcriptSource = ev.transcriptSource;
506
+ }
507
+
412
508
  if (ev.nameSource !== undefined) {
413
- s.transcriptSource = ev.nameSource;
414
509
  void this.refreshName(s, ev.nameSource);
415
510
  }
416
511
 
@@ -469,10 +564,18 @@ export class SessionManager {
469
564
  this.onEvent('state', s);
470
565
  this.emitChange();
471
566
  }
567
+
568
+ return ev.kind;
472
569
  }
473
570
 
474
571
  private async refreshName(s: Session, source: string) {
475
- 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);
476
579
 
477
580
  if (update === null || update.name === '' || update.name === s.name) {
478
581
  return;
@@ -497,7 +600,7 @@ export class SessionManager {
497
600
  return null;
498
601
  }
499
602
 
500
- return this.adapter.buildResumeCommand(s.cwd, s.claudeId);
603
+ return this.findAdapter(s.agent)?.buildResumeCommand(s.cwd, s.agentSessionID) ?? null;
501
604
  }
502
605
 
503
606
  attach(id: string) {
@@ -508,6 +611,9 @@ export class SessionManager {
508
611
  }
509
612
 
510
613
  s.unread = false;
614
+ s.lastAttachedAt = Date.now();
615
+
616
+ this.writeFleet();
511
617
 
512
618
  // Attaching answers the attention request: a still-pending prompt
513
619
  // re-flags it via the next notification.
@@ -590,14 +696,16 @@ export class SessionManager {
590
696
  const fleet: FleetEntry[] = [];
591
697
 
592
698
  for (const s of this.sessions) {
593
- const live = s.pty !== null || (s.kind === 'jsonl' && s.state !== 'exited');
699
+ const live = s.pty !== null || (s.kind === 'headless' && s.state !== 'exited');
594
700
 
595
- if (live && s.claudeId !== undefined) {
701
+ if (live && s.agentSessionID !== undefined) {
596
702
  fleet.push({
597
703
  name: s.name,
598
704
  cwd: s.cwd,
599
- claudeId: s.claudeId,
600
- ...(s.group === undefined ? {} : { group: s.group }),
705
+ agentSessionID: s.agentSessionID,
706
+ agent: s.agent,
707
+ ...(s.pinned ? { pinned: true } : {}),
708
+ lastAttachedAt: s.lastAttachedAt,
601
709
  });
602
710
  }
603
711
  }
@@ -626,10 +734,17 @@ export function countSessionStates(
626
734
  return c;
627
735
  }
628
736
 
629
- // Overlay order: who needs you first, then finished turns, then busy, then dead.
630
- export function sortSessionViews<
631
- T extends { readonly state: SessionState; readonly createdAt: number },
632
- >(list: readonly T[]): T[] {
737
+ interface SortableSessionView {
738
+ readonly state: SessionState;
739
+ readonly pinned: boolean;
740
+ readonly lastAttachedAt: number;
741
+ readonly createdAt: number;
742
+ }
743
+
744
+ // Overlay order: pinned sessions first in most-recently-attached order, then
745
+ // everyone else by urgency — who needs you, finished turns, busy, dead —
746
+ // with most-recently-attached breaking ties inside each state.
747
+ export function sortSessionViews<T extends SortableSessionView>(list: readonly T[]): T[] {
633
748
  const rank: Record<SessionState, number> = {
634
749
  needs_you: 0,
635
750
  done: 1,
@@ -637,24 +752,31 @@ export function sortSessionViews<
637
752
  exited: 3,
638
753
  };
639
754
 
640
- return [...list].toSorted((a, b) => rank[a.state] - rank[b.state] || a.createdAt - b.createdAt);
755
+ return [...list].toSorted((a, b) => {
756
+ if (a.pinned !== b.pinned) {
757
+ return a.pinned ? -1 : 1;
758
+ }
759
+
760
+ const recency = b.lastAttachedAt - a.lastAttachedAt || b.createdAt - a.createdAt;
761
+
762
+ return a.pinned ? recency : rank[a.state] - rank[b.state] || recency;
763
+ });
641
764
  }
642
765
 
643
- // Overlay display order: the urgency sort with each group's sessions pulled
644
- // together at the position of its most urgent member, so the renderer's
645
- // adjacency-based headers appear once per group and urgent groups still lead.
766
+ // Never a filesystem path, so a repository can't collide with it.
767
+ export const PINNED_GROUP_KEY = ' pinned';
768
+
769
+ // Overlay display order for the grouped view: the flat sort with each
770
+ // repository's sessions pulled together at the position of its best-ranked
771
+ // member, so the renderer's adjacency-based headers appear once per group.
772
+ // Pinned sessions form their own leading group.
646
773
  export function sortGroupedSessionViews<
647
- T extends {
648
- readonly state: SessionState;
649
- readonly createdAt: number;
650
- readonly group?: string;
651
- readonly cwd: string;
652
- },
774
+ T extends SortableSessionView & { readonly repoRoot: string },
653
775
  >(list: readonly T[]): T[] {
654
776
  const buckets = new Map<string, T[]>();
655
777
 
656
778
  for (const s of sortSessionViews(list)) {
657
- const key = s.group ?? s.cwd;
779
+ const key = s.pinned ? PINNED_GROUP_KEY : s.repoRoot;
658
780
  const bucket = buckets.get(key);
659
781
 
660
782
  if (bucket === undefined) {
@@ -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,10 +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
- grp TEXT
29
+ pinned INTEGER NOT NULL DEFAULT 0,
30
+ last_attached INTEGER,
31
+ agent TEXT NOT NULL DEFAULT 'claude'
28
32
  );
29
33
  CREATE TABLE IF NOT EXISTS events (
30
34
  id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -38,17 +42,37 @@ export class StateStore {
38
42
  cwd TEXT PRIMARY KEY,
39
43
  last_spawn INTEGER NOT NULL
40
44
  );
45
+ CREATE TABLE IF NOT EXISTS prefs (
46
+ key TEXT PRIMARY KEY,
47
+ value TEXT NOT NULL
48
+ );
41
49
  `);
42
50
 
43
- // Stores created before groups existed lack the column; the create
44
- // above only applies to fresh databases.
45
- const fleetColumns = this.db
46
- .query<{ name: string }, []>('PRAGMA table_info(fleet)')
47
- .all()
48
- .map((c) => c.name);
51
+ // Stores created before pinning, attach recency, or agent kind existed
52
+ // lack the columns; the create above only applies to fresh databases.
53
+ const fleetColumns = new Set(
54
+ this.db
55
+ .query<{ name: string }, []>('PRAGMA table_info(fleet)')
56
+ .all()
57
+ .map((c) => c.name),
58
+ );
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
+
66
+ if (!fleetColumns.has('pinned')) {
67
+ this.db.run('ALTER TABLE fleet ADD COLUMN pinned INTEGER NOT NULL DEFAULT 0');
68
+ }
69
+
70
+ if (!fleetColumns.has('last_attached')) {
71
+ this.db.run('ALTER TABLE fleet ADD COLUMN last_attached INTEGER');
72
+ }
49
73
 
50
- if (!fleetColumns.includes('grp')) {
51
- this.db.run('ALTER TABLE fleet ADD COLUMN grp TEXT');
74
+ if (!fleetColumns.has('agent')) {
75
+ this.db.run("ALTER TABLE fleet ADD COLUMN agent TEXT NOT NULL DEFAULT 'claude'");
52
76
  }
53
77
 
54
78
  if (legacyFleetPath !== undefined) {
@@ -58,20 +82,30 @@ export class StateStore {
58
82
 
59
83
  loadFleet(): FleetEntry[] {
60
84
  const rows = this.db
61
- .query<{ claude_id: string; name: string; cwd: string; grp: string | null }, []>(
62
- 'SELECT claude_id, name, cwd, grp FROM fleet',
63
- )
85
+ .query<
86
+ {
87
+ agent_session_id: string;
88
+ name: string;
89
+ cwd: string;
90
+ pinned: number;
91
+ last_attached: number | null;
92
+ agent: string | null;
93
+ },
94
+ []
95
+ >('SELECT agent_session_id, name, cwd, pinned, last_attached, agent FROM fleet')
64
96
  .all();
65
97
 
66
98
  const entries: FleetEntry[] = [];
67
99
 
68
100
  for (const row of rows) {
69
- const entry: FleetEntry =
70
- row.grp === null
71
- ? { claudeId: row.claude_id, name: row.name, cwd: row.cwd }
72
- : { claudeId: row.claude_id, name: row.name, cwd: row.cwd, group: row.grp };
73
-
74
- entries.push(entry);
101
+ entries.push({
102
+ agentSessionID: row.agent_session_id,
103
+ name: row.name,
104
+ cwd: row.cwd,
105
+ agent: toAgentKind(row.agent),
106
+ ...(row.pinned === 0 ? {} : { pinned: true }),
107
+ ...(row.last_attached === null ? {} : { lastAttachedAt: row.last_attached }),
108
+ });
75
109
  }
76
110
 
77
111
  return entries;
@@ -94,11 +128,20 @@ export class StateStore {
94
128
  this.db.run('DELETE FROM fleet');
95
129
 
96
130
  const insert = this.db.query(
97
- 'INSERT OR REPLACE INTO fleet (claude_id, name, cwd, grp) VALUES (?1, ?2, ?3, ?4)',
131
+ 'INSERT OR REPLACE INTO fleet (agent_session_id, name, cwd, pinned, last_attached, agent) VALUES (?1, ?2, ?3, ?4, ?5, ?6)',
98
132
  );
99
133
 
100
134
  for (const entry of all) {
101
- insert.run(entry.claudeId, entry.name, entry.cwd, entry.group ?? null);
135
+ const pinned = entry.pinned === true ? 1 : 0;
136
+
137
+ insert.run(
138
+ entry.agentSessionID,
139
+ entry.name,
140
+ entry.cwd,
141
+ pinned,
142
+ entry.lastAttachedAt ?? null,
143
+ entry.agent,
144
+ );
102
145
  }
103
146
  });
104
147
 
@@ -107,7 +150,7 @@ export class StateStore {
107
150
 
108
151
  recordEvent(e: HookEvent): void {
109
152
  const rawMessage = e.payload['message'];
110
- const rawSessionID = e.payload['session_id'];
153
+ const rawSessionID = e.payload['session_id'] ?? e.payload['sessionId'];
111
154
  const message = typeof rawMessage === 'string' ? rawMessage : null;
112
155
  const sessionID = typeof rawSessionID === 'string' ? rawSessionID : null;
113
156
 
@@ -134,6 +177,22 @@ export class StateStore {
134
177
  return rows.map((row) => row.cwd);
135
178
  }
136
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
+
137
196
  stop(): void {
138
197
  this.db.close();
139
198
  }
@@ -152,13 +211,15 @@ export class StateStore {
152
211
  return;
153
212
  }
154
213
 
155
- const entries = parsed.filter(
156
- (entry): entry is FleetEntry =>
157
- isRecord(entry) &&
158
- typeof entry['name'] === 'string' &&
159
- typeof entry['cwd'] === 'string' &&
160
- typeof entry['claudeId'] === 'string',
161
- );
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
+ }
162
223
 
163
224
  this.writeFleet(entries);
164
225
  } catch {}