@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/README.md +114 -54
- 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 +36 -2
- package/src/codex-adapter.ts +178 -0
- package/src/collect-clean-env.ts +35 -5
- package/src/config.ts +22 -1
- package/src/daemon-connection.ts +28 -1
- package/src/daemon.ts +58 -57
- package/src/format-overlay-agent-mark.ts +17 -0
- package/src/grok-adapter.ts +274 -0
- package/src/hook-report.ts +8 -4
- package/src/index.ts +96 -15
- package/src/mcp-server.ts +20 -1
- package/src/normalize-hook-event.ts +11 -0
- package/src/pick-tab-target.ts +1 -1
- package/src/print-codex-hook-file.ts +26 -0
- package/src/print-grok-hook-file.ts +27 -0
- package/src/protocol.ts +1 -1
- package/src/sessions.ts +133 -41
- package/src/state-store.ts +62 -18
- package/src/ui.ts +22 -10
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
|
|
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
|
|
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' | '
|
|
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' | '
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
|
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(
|
|
128
|
-
|
|
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
|
|
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.
|
|
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 = '
|
|
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: '
|
|
215
|
+
kind: 'headless',
|
|
165
216
|
pty: null,
|
|
166
217
|
state: 'running',
|
|
167
218
|
unread: false,
|
|
168
219
|
lastMsg: 'waiting to restore',
|
|
169
|
-
|
|
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.
|
|
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 =
|
|
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 !== '
|
|
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 =
|
|
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' ? {
|
|
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.
|
|
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 === '
|
|
462
|
+
alive: s.pty !== null || (s.kind === 'headless' && s.state !== 'exited'),
|
|
396
463
|
}));
|
|
397
464
|
}
|
|
398
465
|
|
|
399
|
-
|
|
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 (
|
|
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.
|
|
413
|
-
s.
|
|
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
|
|
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.
|
|
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 === '
|
|
699
|
+
const live = s.pty !== null || (s.kind === 'headless' && s.state !== 'exited');
|
|
609
700
|
|
|
610
|
-
if (live && s.
|
|
701
|
+
if (live && s.agentSessionID !== undefined) {
|
|
611
702
|
fleet.push({
|
|
612
703
|
name: s.name,
|
|
613
704
|
cwd: s.cwd,
|
|
614
|
-
|
|
705
|
+
agentSessionID: s.agentSessionID,
|
|
706
|
+
agent: s.agent,
|
|
615
707
|
...(s.pinned ? { pinned: true } : {}),
|
|
616
708
|
lastAttachedAt: s.lastAttachedAt,
|
|
617
709
|
});
|
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,11 +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,
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 (
|
|
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(
|
|
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 =
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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' | '
|
|
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 -
|
|
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(
|
|
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 === '
|
|
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
|
-
|
|
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
|
|
374
|
+
'atc — control tower for Claude and Grok sessions',
|
|
363
375
|
'',
|
|
364
376
|
'n spawn a session',
|
|
365
|
-
'r adopt an existing session
|
|
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',
|