@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/README.md +108 -57
- package/package.json +1 -1
- package/src/agent-adapter.ts +44 -0
- package/src/boot-daemon.ts +7 -1
- package/src/build-cli-command.ts +16 -0
- package/src/claude-adapter.ts +26 -18
- package/src/cli.ts +21 -2
- package/src/collect-clean-env.ts +30 -5
- package/src/config.ts +11 -1
- package/src/daemon-connection.ts +25 -7
- package/src/daemon.ts +61 -59
- package/src/format-overlay-agent-mark.ts +9 -0
- package/src/grok-adapter.ts +274 -0
- package/src/hook-report.ts +8 -4
- package/src/index.ts +121 -67
- package/src/mcp-server.ts +16 -4
- package/src/normalize-hook-event.ts +11 -0
- package/src/pick-tab-target.ts +3 -1
- package/src/print-grok-hook-file.ts +27 -0
- package/src/protocol.ts +1 -1
- package/src/resolve-repo-root.ts +51 -0
- package/src/sessions.ts +197 -75
- package/src/state-store.ts +91 -30
- package/src/ui.ts +41 -20
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
|
|
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
|
|
26
|
-
readonly
|
|
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' | '
|
|
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' | '
|
|
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
|
-
|
|
48
|
+
agentSessionID?: string;
|
|
49
|
+
agent: AgentKind;
|
|
44
50
|
transcriptSource?: string;
|
|
45
|
-
|
|
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
|
|
59
|
-
readonly
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
|
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(
|
|
116
|
-
|
|
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
|
|
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.
|
|
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 = '
|
|
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: '
|
|
215
|
+
kind: 'headless',
|
|
153
216
|
pty: null,
|
|
154
217
|
state: 'running',
|
|
155
218
|
unread: false,
|
|
156
219
|
lastMsg: 'waiting to restore',
|
|
157
|
-
|
|
158
|
-
|
|
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.
|
|
241
|
+
if (!s || s.pty !== null || s.agentSessionID === undefined) {
|
|
176
242
|
return null;
|
|
177
243
|
}
|
|
178
244
|
|
|
179
|
-
const
|
|
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
|
|
221
|
-
*
|
|
222
|
-
* in-session rename still wins.
|
|
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,
|
|
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 (
|
|
239
|
-
|
|
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 !== '
|
|
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
|
-
|
|
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 =
|
|
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' ? {
|
|
337
|
-
|
|
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.
|
|
379
|
-
|
|
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 === '
|
|
462
|
+
alive: s.pty !== null || (s.kind === 'headless' && s.state !== 'exited'),
|
|
384
463
|
}));
|
|
385
464
|
}
|
|
386
465
|
|
|
387
|
-
|
|
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 (
|
|
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.
|
|
401
|
-
s.
|
|
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
|
|
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.
|
|
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 === '
|
|
699
|
+
const live = s.pty !== null || (s.kind === 'headless' && s.state !== 'exited');
|
|
594
700
|
|
|
595
|
-
if (live && s.
|
|
701
|
+
if (live && s.agentSessionID !== undefined) {
|
|
596
702
|
fleet.push({
|
|
597
703
|
name: s.name,
|
|
598
704
|
cwd: s.cwd,
|
|
599
|
-
|
|
600
|
-
|
|
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
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
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) =>
|
|
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
|
-
//
|
|
644
|
-
|
|
645
|
-
|
|
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.
|
|
779
|
+
const key = s.pinned ? PINNED_GROUP_KEY : s.repoRoot;
|
|
658
780
|
const bucket = buckets.get(key);
|
|
659
781
|
|
|
660
782
|
if (bucket === undefined) {
|
package/src/state-store.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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,
|
|
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
|
|
44
|
-
// above only applies to fresh databases.
|
|
45
|
-
const fleetColumns =
|
|
46
|
-
.
|
|
47
|
-
|
|
48
|
-
|
|
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.
|
|
51
|
-
this.db.run(
|
|
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<
|
|
62
|
-
|
|
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
|
-
|
|
70
|
-
row.
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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 (
|
|
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
|
-
|
|
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 =
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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 {}
|