@zgeoff/atc 0.1.9 → 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 +101 -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 +23 -1
- package/src/daemon.ts +58 -57
- 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 +79 -15
- package/src/mcp-server.ts +13 -1
- package/src/normalize-hook-event.ts +11 -0
- package/src/pick-tab-target.ts +1 -1
- 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/index.ts
CHANGED
|
@@ -10,16 +10,24 @@ import { countSessionStates, sortGroupedSessionViews, sortSessionViews } from '.
|
|
|
10
10
|
import type { SessionState } from './sessions';
|
|
11
11
|
import { ansi, cols, drawHelp, drawHome, drawOverlay, drawPicker, drawStatusBar, rows } from './ui';
|
|
12
12
|
|
|
13
|
+
type AgentKind = 'claude' | 'grok';
|
|
14
|
+
|
|
13
15
|
type Mode =
|
|
14
16
|
| 'home'
|
|
15
17
|
| 'attached'
|
|
16
18
|
| 'overlay'
|
|
17
19
|
| 'help'
|
|
20
|
+
| 'picker-agent'
|
|
18
21
|
| 'picker-dir'
|
|
19
22
|
| 'picker-name'
|
|
20
23
|
| 'picker-prompt'
|
|
21
24
|
| 'picker-eject';
|
|
22
25
|
|
|
26
|
+
const AGENT_PICKS = [
|
|
27
|
+
{ agent: 'claude' as const, label: 'Claude' },
|
|
28
|
+
{ agent: 'grok' as const, label: 'Grok' },
|
|
29
|
+
];
|
|
30
|
+
|
|
23
31
|
interface MirrorSession {
|
|
24
32
|
id: string;
|
|
25
33
|
name: string;
|
|
@@ -31,9 +39,10 @@ interface MirrorSession {
|
|
|
31
39
|
unread: boolean;
|
|
32
40
|
lastMsg: string;
|
|
33
41
|
createdAt: number;
|
|
34
|
-
kind: 'pty' | '
|
|
42
|
+
kind: 'pty' | 'headless';
|
|
35
43
|
alive: boolean;
|
|
36
44
|
resumable: boolean;
|
|
45
|
+
agent: AgentKind;
|
|
37
46
|
}
|
|
38
47
|
|
|
39
48
|
let mode: Mode = 'home';
|
|
@@ -58,6 +67,8 @@ let pickerSelected = 0;
|
|
|
58
67
|
let spawnDir = '';
|
|
59
68
|
let spawnName = '';
|
|
60
69
|
let spawnResume = false;
|
|
70
|
+
let spawnAgent: AgentKind = 'claude';
|
|
71
|
+
let lastUsedAgent: AgentKind = 'claude';
|
|
61
72
|
const stdout = process.stdout;
|
|
62
73
|
|
|
63
74
|
// Full height: the atc status bar only exists on home/overlay screens;
|
|
@@ -251,7 +262,17 @@ function openOverlay() {
|
|
|
251
262
|
function renderPicker() {
|
|
252
263
|
const verb = spawnResume ? 'adopt' : 'spawn';
|
|
253
264
|
|
|
254
|
-
if (mode === 'picker-
|
|
265
|
+
if (mode === 'picker-agent') {
|
|
266
|
+
pickerSelected = Math.min(pickerSelected, AGENT_PICKS.length - 1);
|
|
267
|
+
|
|
268
|
+
drawPicker({
|
|
269
|
+
title: `${verb}: agent`,
|
|
270
|
+
items: AGENT_PICKS.map((pick) => pick.label),
|
|
271
|
+
selected: pickerSelected,
|
|
272
|
+
input: pickerInput,
|
|
273
|
+
hint: '↑↓ move · ⏎ select · esc cancel',
|
|
274
|
+
});
|
|
275
|
+
} else if (mode === 'picker-dir') {
|
|
255
276
|
const items = pickMatches(allDirs, pickerInput).map((d) => formatDir(d));
|
|
256
277
|
|
|
257
278
|
pickerSelected = Math.min(pickerSelected, Math.max(0, Math.min(items.length, 10) - 1));
|
|
@@ -295,9 +316,19 @@ function renderPicker() {
|
|
|
295
316
|
scheduleStatus();
|
|
296
317
|
}
|
|
297
318
|
|
|
298
|
-
|
|
319
|
+
function openAgentPicker(resume = false) {
|
|
299
320
|
spawnResume = resume;
|
|
321
|
+
spawnAgent = lastUsedAgent;
|
|
322
|
+
pickerInput = '';
|
|
323
|
+
pickerSelected = lastUsedAgent === 'grok' ? 1 : 0;
|
|
324
|
+
mode = 'picker-agent';
|
|
300
325
|
|
|
326
|
+
stdout.write(ansi.clear);
|
|
327
|
+
|
|
328
|
+
renderPicker();
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
async function openDirPicker() {
|
|
301
332
|
let recent: string[] = [];
|
|
302
333
|
|
|
303
334
|
try {
|
|
@@ -344,6 +375,7 @@ async function spawnFromPicker(prompt: string) {
|
|
|
344
375
|
cols: cols(),
|
|
345
376
|
rows: ptyRows(),
|
|
346
377
|
...(spawnResume ? { resume: true } : {}),
|
|
378
|
+
agent: spawnAgent,
|
|
347
379
|
});
|
|
348
380
|
|
|
349
381
|
const spawned = toMirrorSession(ok['session']);
|
|
@@ -407,9 +439,10 @@ function toMirrorSession(value: unknown): MirrorSession | null {
|
|
|
407
439
|
unread: value['unread'],
|
|
408
440
|
lastMsg: value['lastMsg'],
|
|
409
441
|
createdAt: value['createdAt'],
|
|
410
|
-
kind: value['kind'] === '
|
|
442
|
+
kind: value['kind'] === 'headless' ? 'headless' : 'pty',
|
|
411
443
|
alive: value['alive'],
|
|
412
|
-
resumable: typeof value['
|
|
444
|
+
resumable: typeof value['agentSessionID'] === 'string',
|
|
445
|
+
agent: value['agent'] === 'grok' ? 'grok' : 'claude',
|
|
413
446
|
};
|
|
414
447
|
}
|
|
415
448
|
|
|
@@ -429,6 +462,7 @@ function upsertMirror(d: Readonly<MirrorSession>) {
|
|
|
429
462
|
existing.kind = d.kind;
|
|
430
463
|
existing.alive = d.alive;
|
|
431
464
|
existing.resumable = d.resumable;
|
|
465
|
+
existing.agent = d.agent;
|
|
432
466
|
}
|
|
433
467
|
}
|
|
434
468
|
|
|
@@ -487,7 +521,7 @@ function applyDaemonEvent(e: EventMsg) {
|
|
|
487
521
|
s.alive = false;
|
|
488
522
|
}
|
|
489
523
|
|
|
490
|
-
if (e['kind'] === 'pty' || e['kind'] === '
|
|
524
|
+
if (e['kind'] === 'pty' || e['kind'] === 'headless') {
|
|
491
525
|
s.kind = e['kind'];
|
|
492
526
|
}
|
|
493
527
|
|
|
@@ -507,7 +541,15 @@ function applyDaemonEvent(e: EventMsg) {
|
|
|
507
541
|
s.lastMsg = e['lastMsg'];
|
|
508
542
|
}
|
|
509
543
|
|
|
510
|
-
if (
|
|
544
|
+
if (e['agent'] === 'grok' || e['agent'] === 'claude') {
|
|
545
|
+
s.agent = e['agent'];
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
if (typeof e['agentSessionID'] === 'string') {
|
|
549
|
+
if (!s.resumable) {
|
|
550
|
+
lastUsedAgent = s.agent;
|
|
551
|
+
}
|
|
552
|
+
|
|
511
553
|
s.resumable = true;
|
|
512
554
|
}
|
|
513
555
|
}
|
|
@@ -735,7 +777,7 @@ function applyOverlayKey(buf: Buffer) {
|
|
|
735
777
|
}
|
|
736
778
|
|
|
737
779
|
if (ch === 'n') {
|
|
738
|
-
|
|
780
|
+
openAgentPicker();
|
|
739
781
|
|
|
740
782
|
return;
|
|
741
783
|
}
|
|
@@ -772,7 +814,7 @@ function applyOverlayKey(buf: Buffer) {
|
|
|
772
814
|
return;
|
|
773
815
|
}
|
|
774
816
|
|
|
775
|
-
if (ch === 'H' && sel !== undefined && sel.kind === 'pty' && sel.alive) {
|
|
817
|
+
if (ch === 'H' && sel !== undefined && sel.kind === 'pty' && sel.alive && sel.agent !== 'grok') {
|
|
776
818
|
ejectTarget = sel.id;
|
|
777
819
|
pickerInput = '';
|
|
778
820
|
mode = 'picker-eject';
|
|
@@ -784,14 +826,14 @@ function applyOverlayKey(buf: Buffer) {
|
|
|
784
826
|
return;
|
|
785
827
|
}
|
|
786
828
|
|
|
787
|
-
if (ch === 'P' && sel !== undefined && sel.resumable && (sel.kind === '
|
|
829
|
+
if (ch === 'P' && sel !== undefined && sel.resumable && (sel.kind === 'headless' || !sel.alive)) {
|
|
788
830
|
void adoptSession(sel.id);
|
|
789
831
|
|
|
790
832
|
return;
|
|
791
833
|
}
|
|
792
834
|
|
|
793
835
|
if (ch === 'r') {
|
|
794
|
-
|
|
836
|
+
openAgentPicker(true);
|
|
795
837
|
|
|
796
838
|
return;
|
|
797
839
|
}
|
|
@@ -893,7 +935,7 @@ function applyTextKey(buf: Buffer, onSubmit: () => void, onCancel: () => void) {
|
|
|
893
935
|
return;
|
|
894
936
|
}
|
|
895
937
|
|
|
896
|
-
if (mode === 'picker-dir') {
|
|
938
|
+
if (mode === 'picker-dir' || mode === 'picker-agent') {
|
|
897
939
|
if (isDown(buf)) {
|
|
898
940
|
pickerSelected++;
|
|
899
941
|
|
|
@@ -933,6 +975,7 @@ const boot = await bootDaemonClient();
|
|
|
933
975
|
let client = boot.client;
|
|
934
976
|
let daemonStale = boot.stale;
|
|
935
977
|
|
|
978
|
+
lastUsedAgent = boot.lastUsedAgent;
|
|
936
979
|
client.onEvent = applyDaemonEvent;
|
|
937
980
|
|
|
938
981
|
await refreshMirror();
|
|
@@ -989,6 +1032,7 @@ async function restartDaemon() {
|
|
|
989
1032
|
|
|
990
1033
|
client = next.client;
|
|
991
1034
|
daemonStale = next.stale;
|
|
1035
|
+
lastUsedAgent = next.lastUsedAgent;
|
|
992
1036
|
client.onEvent = applyDaemonEvent;
|
|
993
1037
|
|
|
994
1038
|
await sendQuiet('fleet.restore', { cols: cols(), rows: ptyRows() });
|
|
@@ -1032,13 +1076,13 @@ process.stdin.on('data', (buf: Buffer) => {
|
|
|
1032
1076
|
const ch = buf.toString();
|
|
1033
1077
|
|
|
1034
1078
|
if (ch === 'n') {
|
|
1035
|
-
|
|
1079
|
+
openAgentPicker();
|
|
1036
1080
|
|
|
1037
1081
|
return;
|
|
1038
1082
|
}
|
|
1039
1083
|
|
|
1040
1084
|
if (ch === 'r') {
|
|
1041
|
-
|
|
1085
|
+
openAgentPicker(true);
|
|
1042
1086
|
|
|
1043
1087
|
return;
|
|
1044
1088
|
}
|
|
@@ -1065,6 +1109,20 @@ process.stdin.on('data', (buf: Buffer) => {
|
|
|
1065
1109
|
|
|
1066
1110
|
return;
|
|
1067
1111
|
}
|
|
1112
|
+
case 'picker-agent': {
|
|
1113
|
+
applyTextKey(
|
|
1114
|
+
buf,
|
|
1115
|
+
() => {
|
|
1116
|
+
spawnAgent = AGENT_PICKS[pickerSelected]?.agent ?? 'claude';
|
|
1117
|
+
void openDirPicker();
|
|
1118
|
+
},
|
|
1119
|
+
() => {
|
|
1120
|
+
toBase();
|
|
1121
|
+
},
|
|
1122
|
+
);
|
|
1123
|
+
|
|
1124
|
+
return;
|
|
1125
|
+
}
|
|
1068
1126
|
case 'picker-dir': {
|
|
1069
1127
|
applyTextKey(
|
|
1070
1128
|
buf,
|
|
@@ -1090,7 +1148,13 @@ process.stdin.on('data', (buf: Buffer) => {
|
|
|
1090
1148
|
renderPicker();
|
|
1091
1149
|
},
|
|
1092
1150
|
() => {
|
|
1093
|
-
|
|
1151
|
+
pickerInput = '';
|
|
1152
|
+
pickerSelected = spawnAgent === 'grok' ? 1 : 0;
|
|
1153
|
+
mode = 'picker-agent';
|
|
1154
|
+
|
|
1155
|
+
stdout.write(ansi.clear);
|
|
1156
|
+
|
|
1157
|
+
renderPicker();
|
|
1094
1158
|
},
|
|
1095
1159
|
);
|
|
1096
1160
|
|
package/src/mcp-server.ts
CHANGED
|
@@ -43,13 +43,18 @@ const TOOLS: readonly MCPTool[] = [
|
|
|
43
43
|
{
|
|
44
44
|
name: 'atc_session_spawn',
|
|
45
45
|
description:
|
|
46
|
-
'Spawn a new
|
|
46
|
+
'Spawn a new session in a directory. Optional agent is claude or grok; omitted agent is always Claude, never the TUI last-used value. Returns the new session descriptor. Give it a prompt to start it working immediately.',
|
|
47
47
|
inputSchema: {
|
|
48
48
|
type: 'object',
|
|
49
49
|
properties: {
|
|
50
50
|
cwd: { type: 'string', description: 'Absolute path of the working directory' },
|
|
51
51
|
name: { type: 'string', description: 'Session name; defaults to the directory basename' },
|
|
52
52
|
prompt: { type: 'string', description: 'First message for the session' },
|
|
53
|
+
agent: {
|
|
54
|
+
type: 'string',
|
|
55
|
+
enum: ['claude', 'grok'],
|
|
56
|
+
description: 'Which agent CLI to spawn; defaults to claude',
|
|
57
|
+
},
|
|
53
58
|
},
|
|
54
59
|
required: ['cwd'],
|
|
55
60
|
additionalProperties: false,
|
|
@@ -236,10 +241,17 @@ async function runTool(
|
|
|
236
241
|
return JSON.stringify(ok['sessions'], null, 2);
|
|
237
242
|
}
|
|
238
243
|
case 'atc_session_spawn': {
|
|
244
|
+
const rawAgent = args['agent'];
|
|
245
|
+
|
|
246
|
+
if (rawAgent !== undefined && rawAgent !== 'claude' && rawAgent !== 'grok') {
|
|
247
|
+
throw new Error("agent must be 'claude' or 'grok'");
|
|
248
|
+
}
|
|
249
|
+
|
|
239
250
|
const ok = await client.sendRequest('session.spawn', {
|
|
240
251
|
cwd: args['cwd'],
|
|
241
252
|
...(typeof args['name'] === 'string' ? { name: args['name'] } : {}),
|
|
242
253
|
...(typeof args['prompt'] === 'string' ? { prompt: args['prompt'] } : {}),
|
|
254
|
+
...(rawAgent === 'claude' || rawAgent === 'grok' ? { agent: rawAgent } : {}),
|
|
243
255
|
cols: 100,
|
|
244
256
|
rows: 30,
|
|
245
257
|
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonicalize a hook event name to PascalCase. Claude already sends
|
|
3
|
+
* PascalCase (`SessionStart`); Grok sends snake_case (`session_start`).
|
|
4
|
+
* Restore stagger and the adapter switch compare the PascalCase form.
|
|
5
|
+
*/
|
|
6
|
+
export function normalizeHookEventName(raw: string): string {
|
|
7
|
+
return raw
|
|
8
|
+
.split('_')
|
|
9
|
+
.map((part) => `${part.charAt(0).toUpperCase()}${part.slice(1)}`)
|
|
10
|
+
.join('');
|
|
11
|
+
}
|
package/src/pick-tab-target.ts
CHANGED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { buildCLICommand } from './build-cli-command';
|
|
2
|
+
|
|
3
|
+
const GROK_HOOK_EVENTS = [
|
|
4
|
+
'SessionStart',
|
|
5
|
+
'SessionEnd',
|
|
6
|
+
'UserPromptSubmit',
|
|
7
|
+
'Stop',
|
|
8
|
+
'StopFailure',
|
|
9
|
+
'StopCancelled',
|
|
10
|
+
'Notification',
|
|
11
|
+
] as const;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Print the Grok hook file to stdout. The operator copies it to
|
|
15
|
+
* `$GROK_HOME/hooks/atc-reporter.json`; atc never writes that path.
|
|
16
|
+
*/
|
|
17
|
+
export function printGrokHookFile(): void {
|
|
18
|
+
process.stdout.write(buildGrokHookFile());
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function buildGrokHookFile(): string {
|
|
22
|
+
const cmd = buildCLICommand('hook-report');
|
|
23
|
+
const entry = [{ hooks: [{ type: 'command', command: cmd, timeout: 5 }] }];
|
|
24
|
+
const hooks = Object.fromEntries(GROK_HOOK_EVENTS.map((event) => [event, entry]));
|
|
25
|
+
|
|
26
|
+
return `${JSON.stringify({ hooks }, null, 2)}\n`;
|
|
27
|
+
}
|
package/src/protocol.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { isRecord } from './report';
|
|
|
5
5
|
* per line. Three message kinds, distinguished by which fields are present —
|
|
6
6
|
* request (id + m), response (id + ok or err), event (ev).
|
|
7
7
|
*/
|
|
8
|
-
export const PROTOCOL_V =
|
|
8
|
+
export const PROTOCOL_V = 2;
|
|
9
9
|
|
|
10
10
|
// Control lines are capped before buffering; PTY output is split into chunks
|
|
11
11
|
// so a queued response is delayed by at most one chunk.
|
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
|
});
|