@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/index.ts
CHANGED
|
@@ -10,29 +10,39 @@ 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
|
-
| 'picker-group'
|
|
21
23
|
| 'picker-prompt'
|
|
22
24
|
| 'picker-eject';
|
|
23
25
|
|
|
26
|
+
const AGENT_PICKS = [
|
|
27
|
+
{ agent: 'claude' as const, label: 'Claude' },
|
|
28
|
+
{ agent: 'grok' as const, label: 'Grok' },
|
|
29
|
+
];
|
|
30
|
+
|
|
24
31
|
interface MirrorSession {
|
|
25
32
|
id: string;
|
|
26
33
|
name: string;
|
|
27
34
|
cwd: string;
|
|
28
|
-
|
|
35
|
+
pinned: boolean;
|
|
36
|
+
lastAttachedAt: number;
|
|
37
|
+
repoRoot: string;
|
|
29
38
|
state: SessionState;
|
|
30
39
|
unread: boolean;
|
|
31
40
|
lastMsg: string;
|
|
32
41
|
createdAt: number;
|
|
33
|
-
kind: 'pty' | '
|
|
42
|
+
kind: 'pty' | 'headless';
|
|
34
43
|
alive: boolean;
|
|
35
44
|
resumable: boolean;
|
|
45
|
+
agent: AgentKind;
|
|
36
46
|
}
|
|
37
47
|
|
|
38
48
|
let mode: Mode = 'home';
|
|
@@ -56,8 +66,9 @@ let pickerInput = '';
|
|
|
56
66
|
let pickerSelected = 0;
|
|
57
67
|
let spawnDir = '';
|
|
58
68
|
let spawnName = '';
|
|
59
|
-
let spawnGroup = '';
|
|
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;
|
|
@@ -202,8 +213,11 @@ function applyHelpKey(buf: Buffer) {
|
|
|
202
213
|
// fzf-style overlay search: null when inactive, the pattern while active.
|
|
203
214
|
let overlayFilter: string | null = null;
|
|
204
215
|
|
|
216
|
+
// Flat list by default; g toggles clustering under repository headers.
|
|
217
|
+
let overlayGrouped = false;
|
|
218
|
+
|
|
205
219
|
function pickOverlaySessions(): MirrorSession[] {
|
|
206
|
-
const sorted = sortGroupedSessionViews(fleet);
|
|
220
|
+
const sorted = overlayGrouped ? sortGroupedSessionViews(fleet) : sortSessionViews(fleet);
|
|
207
221
|
|
|
208
222
|
if (overlayFilter === null || overlayFilter === '') {
|
|
209
223
|
return sorted;
|
|
@@ -211,9 +225,7 @@ function pickOverlaySessions(): MirrorSession[] {
|
|
|
211
225
|
|
|
212
226
|
const f = overlayFilter;
|
|
213
227
|
|
|
214
|
-
return sorted.filter(
|
|
215
|
-
(s) => findFuzzyScore(`${s.name} ${s.group ?? ''} ${formatDir(s.cwd)}`, f) !== null,
|
|
216
|
-
);
|
|
228
|
+
return sorted.filter((s) => findFuzzyScore(`${s.name} ${formatDir(s.cwd)}`, f) !== null);
|
|
217
229
|
}
|
|
218
230
|
|
|
219
231
|
function renderOverlay() {
|
|
@@ -227,6 +239,7 @@ function renderOverlay() {
|
|
|
227
239
|
confirmKill,
|
|
228
240
|
filter: overlayFilter,
|
|
229
241
|
stale: daemonStale,
|
|
242
|
+
grouped: overlayGrouped,
|
|
230
243
|
});
|
|
231
244
|
|
|
232
245
|
scheduleStatus();
|
|
@@ -237,7 +250,7 @@ function openOverlay() {
|
|
|
237
250
|
confirmKill = false;
|
|
238
251
|
overlayFilter = null;
|
|
239
252
|
|
|
240
|
-
const focusedIndex =
|
|
253
|
+
const focusedIndex = pickOverlaySessions().findIndex((s) => s.id === focusedID);
|
|
241
254
|
|
|
242
255
|
overlaySelected = Math.max(0, focusedIndex);
|
|
243
256
|
|
|
@@ -249,7 +262,17 @@ function openOverlay() {
|
|
|
249
262
|
function renderPicker() {
|
|
250
263
|
const verb = spawnResume ? 'adopt' : 'spawn';
|
|
251
264
|
|
|
252
|
-
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') {
|
|
253
276
|
const items = pickMatches(allDirs, pickerInput).map((d) => formatDir(d));
|
|
254
277
|
|
|
255
278
|
pickerSelected = Math.min(pickerSelected, Math.max(0, Math.min(items.length, 10) - 1));
|
|
@@ -270,18 +293,6 @@ function renderPicker() {
|
|
|
270
293
|
placeholder: basename(spawnDir),
|
|
271
294
|
hint: `session name for ${formatDir(spawnDir)} · ⏎ accept · esc back`,
|
|
272
295
|
});
|
|
273
|
-
} else if (mode === 'picker-group') {
|
|
274
|
-
const groups = [...new Set(fleet.map((s) => s.group).filter((g) => g !== undefined))];
|
|
275
|
-
const known = groups.length === 0 ? '' : ` · existing: ${groups.slice(0, 4).join(', ')}`;
|
|
276
|
-
|
|
277
|
-
drawPicker({
|
|
278
|
-
title: `${verb}: group`,
|
|
279
|
-
items: [],
|
|
280
|
-
selected: -1,
|
|
281
|
-
input: pickerInput,
|
|
282
|
-
placeholder: 'optional — ⏎ to skip',
|
|
283
|
-
hint: `group for the overlay hierarchy${known} · ⏎ accept · esc back`,
|
|
284
|
-
});
|
|
285
296
|
} else if (mode === 'picker-eject') {
|
|
286
297
|
drawPicker({
|
|
287
298
|
title: 'eject: headless instruction',
|
|
@@ -305,10 +316,19 @@ function renderPicker() {
|
|
|
305
316
|
scheduleStatus();
|
|
306
317
|
}
|
|
307
318
|
|
|
308
|
-
|
|
319
|
+
function openAgentPicker(resume = false) {
|
|
309
320
|
spawnResume = resume;
|
|
310
|
-
|
|
321
|
+
spawnAgent = lastUsedAgent;
|
|
322
|
+
pickerInput = '';
|
|
323
|
+
pickerSelected = lastUsedAgent === 'grok' ? 1 : 0;
|
|
324
|
+
mode = 'picker-agent';
|
|
325
|
+
|
|
326
|
+
stdout.write(ansi.clear);
|
|
327
|
+
|
|
328
|
+
renderPicker();
|
|
329
|
+
}
|
|
311
330
|
|
|
331
|
+
async function openDirPicker() {
|
|
312
332
|
let recent: string[] = [];
|
|
313
333
|
|
|
314
334
|
try {
|
|
@@ -355,7 +375,7 @@ async function spawnFromPicker(prompt: string) {
|
|
|
355
375
|
cols: cols(),
|
|
356
376
|
rows: ptyRows(),
|
|
357
377
|
...(spawnResume ? { resume: true } : {}),
|
|
358
|
-
|
|
378
|
+
agent: spawnAgent,
|
|
359
379
|
});
|
|
360
380
|
|
|
361
381
|
const spawned = toMirrorSession(ok['session']);
|
|
@@ -411,14 +431,18 @@ function toMirrorSession(value: unknown): MirrorSession | null {
|
|
|
411
431
|
id: value['id'],
|
|
412
432
|
name: value['name'],
|
|
413
433
|
cwd: value['cwd'],
|
|
414
|
-
|
|
434
|
+
pinned: value['pinned'] === true,
|
|
435
|
+
lastAttachedAt:
|
|
436
|
+
typeof value['lastAttachedAt'] === 'number' ? value['lastAttachedAt'] : value['createdAt'],
|
|
437
|
+
repoRoot: typeof value['repoRoot'] === 'string' ? value['repoRoot'] : value['cwd'],
|
|
415
438
|
state,
|
|
416
439
|
unread: value['unread'],
|
|
417
440
|
lastMsg: value['lastMsg'],
|
|
418
441
|
createdAt: value['createdAt'],
|
|
419
|
-
kind: value['kind'] === '
|
|
442
|
+
kind: value['kind'] === 'headless' ? 'headless' : 'pty',
|
|
420
443
|
alive: value['alive'],
|
|
421
|
-
resumable: typeof value['
|
|
444
|
+
resumable: typeof value['agentSessionID'] === 'string',
|
|
445
|
+
agent: value['agent'] === 'grok' ? 'grok' : 'claude',
|
|
422
446
|
};
|
|
423
447
|
}
|
|
424
448
|
|
|
@@ -429,12 +453,16 @@ function upsertMirror(d: Readonly<MirrorSession>) {
|
|
|
429
453
|
fleet.push({ ...d });
|
|
430
454
|
} else {
|
|
431
455
|
existing.name = d.name;
|
|
456
|
+
existing.pinned = d.pinned;
|
|
457
|
+
existing.lastAttachedAt = d.lastAttachedAt;
|
|
458
|
+
existing.repoRoot = d.repoRoot;
|
|
432
459
|
existing.state = d.state;
|
|
433
460
|
existing.unread = d.unread;
|
|
434
461
|
existing.lastMsg = d.lastMsg;
|
|
435
462
|
existing.kind = d.kind;
|
|
436
463
|
existing.alive = d.alive;
|
|
437
464
|
existing.resumable = d.resumable;
|
|
465
|
+
existing.agent = d.agent;
|
|
438
466
|
}
|
|
439
467
|
}
|
|
440
468
|
|
|
@@ -493,16 +521,16 @@ function applyDaemonEvent(e: EventMsg) {
|
|
|
493
521
|
s.alive = false;
|
|
494
522
|
}
|
|
495
523
|
|
|
496
|
-
if (e['kind'] === 'pty' || e['kind'] === '
|
|
524
|
+
if (e['kind'] === 'pty' || e['kind'] === 'headless') {
|
|
497
525
|
s.kind = e['kind'];
|
|
498
526
|
}
|
|
499
527
|
|
|
500
|
-
if (typeof e['
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
528
|
+
if (typeof e['pinned'] === 'boolean') {
|
|
529
|
+
s.pinned = e['pinned'];
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
if (typeof e['lastAttachedAt'] === 'number') {
|
|
533
|
+
s.lastAttachedAt = e['lastAttachedAt'];
|
|
506
534
|
}
|
|
507
535
|
|
|
508
536
|
if (typeof e['unread'] === 'boolean') {
|
|
@@ -513,7 +541,15 @@ function applyDaemonEvent(e: EventMsg) {
|
|
|
513
541
|
s.lastMsg = e['lastMsg'];
|
|
514
542
|
}
|
|
515
543
|
|
|
516
|
-
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
|
+
|
|
517
553
|
s.resumable = true;
|
|
518
554
|
}
|
|
519
555
|
}
|
|
@@ -741,7 +777,27 @@ function applyOverlayKey(buf: Buffer) {
|
|
|
741
777
|
}
|
|
742
778
|
|
|
743
779
|
if (ch === 'n') {
|
|
744
|
-
|
|
780
|
+
openAgentPicker();
|
|
781
|
+
|
|
782
|
+
return;
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
if (ch === 'g') {
|
|
786
|
+
overlayGrouped = !overlayGrouped;
|
|
787
|
+
|
|
788
|
+
stdout.write(ansi.clear);
|
|
789
|
+
|
|
790
|
+
renderOverlay();
|
|
791
|
+
|
|
792
|
+
return;
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
if (ch === 'p' && sel !== undefined) {
|
|
796
|
+
// Flipped locally too so the repaint is immediate; the daemon's state
|
|
797
|
+
// event confirms it.
|
|
798
|
+
sel.pinned = !sel.pinned;
|
|
799
|
+
void sendQuiet('session.update', { session: sel.id, pinned: sel.pinned });
|
|
800
|
+
renderOverlay();
|
|
745
801
|
|
|
746
802
|
return;
|
|
747
803
|
}
|
|
@@ -758,7 +814,7 @@ function applyOverlayKey(buf: Buffer) {
|
|
|
758
814
|
return;
|
|
759
815
|
}
|
|
760
816
|
|
|
761
|
-
if (ch === 'H' && sel !== undefined && sel.kind === 'pty' && sel.alive) {
|
|
817
|
+
if (ch === 'H' && sel !== undefined && sel.kind === 'pty' && sel.alive && sel.agent !== 'grok') {
|
|
762
818
|
ejectTarget = sel.id;
|
|
763
819
|
pickerInput = '';
|
|
764
820
|
mode = 'picker-eject';
|
|
@@ -770,14 +826,14 @@ function applyOverlayKey(buf: Buffer) {
|
|
|
770
826
|
return;
|
|
771
827
|
}
|
|
772
828
|
|
|
773
|
-
if (ch === 'P' && sel !== undefined && sel.resumable && (sel.kind === '
|
|
829
|
+
if (ch === 'P' && sel !== undefined && sel.resumable && (sel.kind === 'headless' || !sel.alive)) {
|
|
774
830
|
void adoptSession(sel.id);
|
|
775
831
|
|
|
776
832
|
return;
|
|
777
833
|
}
|
|
778
834
|
|
|
779
835
|
if (ch === 'r') {
|
|
780
|
-
|
|
836
|
+
openAgentPicker(true);
|
|
781
837
|
|
|
782
838
|
return;
|
|
783
839
|
}
|
|
@@ -879,7 +935,7 @@ function applyTextKey(buf: Buffer, onSubmit: () => void, onCancel: () => void) {
|
|
|
879
935
|
return;
|
|
880
936
|
}
|
|
881
937
|
|
|
882
|
-
if (mode === 'picker-dir') {
|
|
938
|
+
if (mode === 'picker-dir' || mode === 'picker-agent') {
|
|
883
939
|
if (isDown(buf)) {
|
|
884
940
|
pickerSelected++;
|
|
885
941
|
|
|
@@ -919,6 +975,7 @@ const boot = await bootDaemonClient();
|
|
|
919
975
|
let client = boot.client;
|
|
920
976
|
let daemonStale = boot.stale;
|
|
921
977
|
|
|
978
|
+
lastUsedAgent = boot.lastUsedAgent;
|
|
922
979
|
client.onEvent = applyDaemonEvent;
|
|
923
980
|
|
|
924
981
|
await refreshMirror();
|
|
@@ -975,6 +1032,7 @@ async function restartDaemon() {
|
|
|
975
1032
|
|
|
976
1033
|
client = next.client;
|
|
977
1034
|
daemonStale = next.stale;
|
|
1035
|
+
lastUsedAgent = next.lastUsedAgent;
|
|
978
1036
|
client.onEvent = applyDaemonEvent;
|
|
979
1037
|
|
|
980
1038
|
await sendQuiet('fleet.restore', { cols: cols(), rows: ptyRows() });
|
|
@@ -1018,13 +1076,13 @@ process.stdin.on('data', (buf: Buffer) => {
|
|
|
1018
1076
|
const ch = buf.toString();
|
|
1019
1077
|
|
|
1020
1078
|
if (ch === 'n') {
|
|
1021
|
-
|
|
1079
|
+
openAgentPicker();
|
|
1022
1080
|
|
|
1023
1081
|
return;
|
|
1024
1082
|
}
|
|
1025
1083
|
|
|
1026
1084
|
if (ch === 'r') {
|
|
1027
|
-
|
|
1085
|
+
openAgentPicker(true);
|
|
1028
1086
|
|
|
1029
1087
|
return;
|
|
1030
1088
|
}
|
|
@@ -1051,6 +1109,20 @@ process.stdin.on('data', (buf: Buffer) => {
|
|
|
1051
1109
|
|
|
1052
1110
|
return;
|
|
1053
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
|
+
}
|
|
1054
1126
|
case 'picker-dir': {
|
|
1055
1127
|
applyTextKey(
|
|
1056
1128
|
buf,
|
|
@@ -1075,28 +1147,10 @@ process.stdin.on('data', (buf: Buffer) => {
|
|
|
1075
1147
|
|
|
1076
1148
|
renderPicker();
|
|
1077
1149
|
},
|
|
1078
|
-
() => {
|
|
1079
|
-
toBase();
|
|
1080
|
-
},
|
|
1081
|
-
);
|
|
1082
|
-
|
|
1083
|
-
return;
|
|
1084
|
-
}
|
|
1085
|
-
case 'picker-name': {
|
|
1086
|
-
applyTextKey(
|
|
1087
|
-
buf,
|
|
1088
|
-
() => {
|
|
1089
|
-
spawnName = pickerInput.trim();
|
|
1090
|
-
pickerInput = '';
|
|
1091
|
-
mode = 'picker-group';
|
|
1092
|
-
|
|
1093
|
-
stdout.write(ansi.clear);
|
|
1094
|
-
|
|
1095
|
-
renderPicker();
|
|
1096
|
-
},
|
|
1097
1150
|
() => {
|
|
1098
1151
|
pickerInput = '';
|
|
1099
|
-
|
|
1152
|
+
pickerSelected = spawnAgent === 'grok' ? 1 : 0;
|
|
1153
|
+
mode = 'picker-agent';
|
|
1100
1154
|
|
|
1101
1155
|
stdout.write(ansi.clear);
|
|
1102
1156
|
|
|
@@ -1106,11 +1160,11 @@ process.stdin.on('data', (buf: Buffer) => {
|
|
|
1106
1160
|
|
|
1107
1161
|
return;
|
|
1108
1162
|
}
|
|
1109
|
-
case 'picker-
|
|
1163
|
+
case 'picker-name': {
|
|
1110
1164
|
applyTextKey(
|
|
1111
1165
|
buf,
|
|
1112
1166
|
() => {
|
|
1113
|
-
|
|
1167
|
+
spawnName = pickerInput.trim();
|
|
1114
1168
|
pickerInput = '';
|
|
1115
1169
|
|
|
1116
1170
|
// Adopt skips the prompt step: claude --resume opens its own
|
|
@@ -1129,7 +1183,7 @@ process.stdin.on('data', (buf: Buffer) => {
|
|
|
1129
1183
|
},
|
|
1130
1184
|
() => {
|
|
1131
1185
|
pickerInput = '';
|
|
1132
|
-
mode = 'picker-
|
|
1186
|
+
mode = 'picker-dir';
|
|
1133
1187
|
|
|
1134
1188
|
stdout.write(ansi.clear);
|
|
1135
1189
|
|
|
@@ -1147,7 +1201,7 @@ process.stdin.on('data', (buf: Buffer) => {
|
|
|
1147
1201
|
},
|
|
1148
1202
|
() => {
|
|
1149
1203
|
pickerInput = '';
|
|
1150
|
-
mode = 'picker-
|
|
1204
|
+
mode = 'picker-name';
|
|
1151
1205
|
|
|
1152
1206
|
stdout.write(ansi.clear);
|
|
1153
1207
|
|
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,
|
|
@@ -72,13 +77,13 @@ const TOOLS: readonly MCPTool[] = [
|
|
|
72
77
|
{
|
|
73
78
|
name: 'atc_session_update',
|
|
74
79
|
description:
|
|
75
|
-
'Rename and/or
|
|
80
|
+
'Rename and/or pin a session. Renames stick against auto-summaries; pinned sessions lead every list. Use this to organise the fleet: name sessions after their task.',
|
|
76
81
|
inputSchema: {
|
|
77
82
|
type: 'object',
|
|
78
83
|
properties: {
|
|
79
84
|
session: { type: 'string', description: 'The atc session id' },
|
|
80
85
|
name: { type: 'string', description: 'New display name; omit to keep' },
|
|
81
|
-
|
|
86
|
+
pinned: { type: 'boolean', description: 'Pin or unpin; omit to keep' },
|
|
82
87
|
},
|
|
83
88
|
required: ['session'],
|
|
84
89
|
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
|
});
|
|
@@ -258,7 +270,7 @@ async function runTool(
|
|
|
258
270
|
await client.sendRequest('session.update', {
|
|
259
271
|
session: args['session'],
|
|
260
272
|
...(typeof args['name'] === 'string' ? { name: args['name'] } : {}),
|
|
261
|
-
...(typeof args['
|
|
273
|
+
...(typeof args['pinned'] === 'boolean' ? { pinned: args['pinned'] } : {}),
|
|
262
274
|
});
|
|
263
275
|
|
|
264
276
|
return 'updated';
|
|
@@ -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
|
@@ -4,9 +4,11 @@ import { sortSessionViews } from './sessions';
|
|
|
4
4
|
interface TabCandidate {
|
|
5
5
|
readonly id: string;
|
|
6
6
|
readonly state: SessionState;
|
|
7
|
+
readonly pinned: boolean;
|
|
8
|
+
readonly lastAttachedAt: number;
|
|
7
9
|
readonly createdAt: number;
|
|
8
10
|
readonly alive: boolean;
|
|
9
|
-
readonly kind: 'pty' | '
|
|
11
|
+
readonly kind: 'pty' | 'headless';
|
|
10
12
|
}
|
|
11
13
|
|
|
12
14
|
/**
|
|
@@ -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.
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { readFileSync, statSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The repository a directory belongs to, for clustering sessions in the
|
|
6
|
+
* overlay. Walks toward the filesystem root looking for a `.git` entry: a
|
|
7
|
+
* directory marks the repository root itself, and a linked worktree's `.git`
|
|
8
|
+
* file points back at the main repository, so worktrees cluster with it. A
|
|
9
|
+
* directory outside any repository resolves to itself.
|
|
10
|
+
*/
|
|
11
|
+
export function resolveRepoRoot(cwd: string): string {
|
|
12
|
+
let dir = cwd;
|
|
13
|
+
|
|
14
|
+
while (true) {
|
|
15
|
+
const marker = join(dir, '.git');
|
|
16
|
+
const stat = statSync(marker, { throwIfNoEntry: false });
|
|
17
|
+
|
|
18
|
+
if (stat !== undefined && stat.isDirectory()) {
|
|
19
|
+
return dir;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (stat !== undefined && stat.isFile()) {
|
|
23
|
+
return findMainRoot(marker) ?? dir;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const parent = dirname(dir);
|
|
27
|
+
|
|
28
|
+
if (parent === dir) {
|
|
29
|
+
return cwd;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
dir = parent;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// A linked worktree's `.git` file reads `gitdir: <main>/.git/worktrees/<name>`.
|
|
37
|
+
function findMainRoot(gitFile: string): string | null {
|
|
38
|
+
try {
|
|
39
|
+
const gitdir = /^gitdir:\s*(?<dir>.+)$/mu
|
|
40
|
+
.exec(readFileSync(gitFile, 'utf8'))
|
|
41
|
+
?.groups?.['dir']?.trim();
|
|
42
|
+
|
|
43
|
+
if (gitdir === undefined) {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return /^(?<root>.+)\/\.git\/worktrees\/[^/]+$/u.exec(gitdir)?.groups?.['root'] ?? null;
|
|
48
|
+
} catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
}
|