@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
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import type {
|
|
5
|
+
AdapterEvent,
|
|
6
|
+
AgentAdapter,
|
|
7
|
+
NameUpdate,
|
|
8
|
+
ResumeCheck,
|
|
9
|
+
SpawnOptions,
|
|
10
|
+
SpawnPlan,
|
|
11
|
+
} from './agent-adapter';
|
|
12
|
+
import type { Config } from './config';
|
|
13
|
+
import type { HookEvent } from './hooks';
|
|
14
|
+
import { normalizeHookEventName } from './normalize-hook-event';
|
|
15
|
+
import { isRecord } from './report';
|
|
16
|
+
|
|
17
|
+
interface GrokSessionHookState {
|
|
18
|
+
latestPromptID?: string;
|
|
19
|
+
lastKind: AdapterEvent['kind'];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The Grok Build adapter: spawn arguments, hook payload mapping,
|
|
24
|
+
* resume semantics, and summary.json name-pulling.
|
|
25
|
+
*/
|
|
26
|
+
export class GrokAdapter implements AgentAdapter {
|
|
27
|
+
readonly kind = 'grok';
|
|
28
|
+
|
|
29
|
+
// Grok has no headless handoff.
|
|
30
|
+
readonly headlessRunner = null;
|
|
31
|
+
|
|
32
|
+
// Grok's hooks are authoritative; no screen heuristics needed.
|
|
33
|
+
readonly screenDetector = null;
|
|
34
|
+
|
|
35
|
+
private readonly config: Config;
|
|
36
|
+
|
|
37
|
+
private readonly hookState = new Map<string, GrokSessionHookState>();
|
|
38
|
+
|
|
39
|
+
constructor(config: Config) {
|
|
40
|
+
this.config = config;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
planSpawn(opts: SpawnOptions): SpawnPlan {
|
|
44
|
+
return {
|
|
45
|
+
bin: this.config.grokBin,
|
|
46
|
+
args: [
|
|
47
|
+
...this.config.grokArgs.filter((arg) => arg !== '--leader' && arg !== '--no-leader'),
|
|
48
|
+
'--no-leader',
|
|
49
|
+
...(typeof opts.resume === 'string' ? ['--resume', opts.resume] : []),
|
|
50
|
+
...(opts.prompt === '' ? [] : [opts.prompt]),
|
|
51
|
+
],
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
normalizeHook(e: HookEvent): AdapterEvent {
|
|
56
|
+
const sessionID = e.payload['sessionId'];
|
|
57
|
+
const cwd = e.payload['cwd'];
|
|
58
|
+
const promptID = e.payload['promptId'];
|
|
59
|
+
const subagentType = e.payload['subagentType'];
|
|
60
|
+
|
|
61
|
+
const base: AdapterEvent = {
|
|
62
|
+
kind: 'heartbeat',
|
|
63
|
+
...(typeof sessionID === 'string' ? { agentSessionID: sessionID } : {}),
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const named: AdapterEvent = {
|
|
67
|
+
...base,
|
|
68
|
+
...(typeof sessionID === 'string' && typeof cwd === 'string'
|
|
69
|
+
? { nameSource: buildGrokNameSource(sessionID, cwd) }
|
|
70
|
+
: {}),
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
if (typeof subagentType === 'string' && subagentType !== '') {
|
|
74
|
+
return this.emitHook(e.atcId, { ...base, kind: 'heartbeat' });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const event = normalizeHookEventName(e.event);
|
|
78
|
+
const prior = this.hookState.get(e.atcId);
|
|
79
|
+
|
|
80
|
+
const stale =
|
|
81
|
+
typeof promptID === 'string' &&
|
|
82
|
+
prior?.latestPromptID !== undefined &&
|
|
83
|
+
promptID !== prior.latestPromptID;
|
|
84
|
+
|
|
85
|
+
switch (event) {
|
|
86
|
+
case 'SessionStart': {
|
|
87
|
+
return this.emitHook(e.atcId, { ...named, kind: 'started' });
|
|
88
|
+
}
|
|
89
|
+
case 'UserPromptSubmit': {
|
|
90
|
+
const prompt = e.payload['prompt'];
|
|
91
|
+
const preview = typeof prompt === 'string' ? prompt.slice(0, 80) : '';
|
|
92
|
+
const submittedID = typeof promptID === 'string' ? promptID : undefined;
|
|
93
|
+
|
|
94
|
+
return this.emitHook(
|
|
95
|
+
e.atcId,
|
|
96
|
+
{
|
|
97
|
+
...named,
|
|
98
|
+
kind: 'prompt-submitted',
|
|
99
|
+
...(preview === '' ? {} : { message: preview, detail: truncateDetail(preview) }),
|
|
100
|
+
},
|
|
101
|
+
submittedID,
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
case 'Notification': {
|
|
105
|
+
const notificationType = e.payload['notificationType'];
|
|
106
|
+
const message = e.payload['message'];
|
|
107
|
+
|
|
108
|
+
if (notificationType === 'permission_prompt') {
|
|
109
|
+
return this.emitHook(e.atcId, {
|
|
110
|
+
...base,
|
|
111
|
+
kind: 'needs-input',
|
|
112
|
+
...(typeof message === 'string' && message !== ''
|
|
113
|
+
? { message, detail: truncateDetail(message) }
|
|
114
|
+
: {}),
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (notificationType === 'idle_prompt') {
|
|
119
|
+
const kind = prior?.lastKind === 'needs-input' ? 'heartbeat' : 'turn-done';
|
|
120
|
+
|
|
121
|
+
return this.emitHook(e.atcId, { ...named, kind });
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return this.emitHook(e.atcId, base);
|
|
125
|
+
}
|
|
126
|
+
case 'Stop': {
|
|
127
|
+
if (e.payload['reason'] !== 'end_turn' || stale) {
|
|
128
|
+
return this.emitHook(e.atcId, base);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return this.emitHook(e.atcId, buildTurnDoneEvent(named, e.payload));
|
|
132
|
+
}
|
|
133
|
+
case 'StopFailure':
|
|
134
|
+
case 'StopCancelled': {
|
|
135
|
+
if (stale) {
|
|
136
|
+
return this.emitHook(e.atcId, base);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return this.emitHook(e.atcId, buildTurnDoneEvent(named, e.payload));
|
|
140
|
+
}
|
|
141
|
+
case 'SessionEnd': {
|
|
142
|
+
this.hookState.delete(e.atcId);
|
|
143
|
+
|
|
144
|
+
return { ...base, kind: 'ended' };
|
|
145
|
+
}
|
|
146
|
+
default: {
|
|
147
|
+
return this.emitHook(e.atcId, base);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
loadName(source: string, namedBy: 'user' | 'auto' | 'agent'): Promise<NameUpdate | null> {
|
|
153
|
+
const parsed = readSummary(source) ?? readSummary(findGrokSummary(source));
|
|
154
|
+
|
|
155
|
+
if (parsed === null) {
|
|
156
|
+
return Promise.resolve(null);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const generated = parsed['generated_title'];
|
|
160
|
+
const summary = parsed['session_summary'];
|
|
161
|
+
const title = typeof generated === 'string' ? generated : '';
|
|
162
|
+
const sessionSummary = typeof summary === 'string' ? summary : '';
|
|
163
|
+
|
|
164
|
+
if (parsed['title_is_manual'] === true && title !== '') {
|
|
165
|
+
return Promise.resolve({ name: title, namedBy: 'agent' });
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (namedBy !== 'user' && title !== '') {
|
|
169
|
+
return Promise.resolve({ name: title });
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (namedBy !== 'user' && sessionSummary !== '') {
|
|
173
|
+
return Promise.resolve({ name: sessionSummary });
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return Promise.resolve(null);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
canResume(session: ResumeCheck): boolean {
|
|
180
|
+
return session.agentSessionID !== undefined;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Shell command that re-opens this session outside atc (or anywhere).
|
|
184
|
+
buildResumeCommand(cwd: string, agentSessionID: string | undefined): string | null {
|
|
185
|
+
const resume = agentSessionID === undefined ? 'grok' : `grok --resume ${agentSessionID}`;
|
|
186
|
+
const quoted = cwd.replaceAll("'", String.raw`'\''`);
|
|
187
|
+
|
|
188
|
+
return `cd '${quoted}' && ${resume}`;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
private emitHook(atcID: string, event: Readonly<AdapterEvent>, promptID?: string): AdapterEvent {
|
|
192
|
+
const prior = this.hookState.get(atcID);
|
|
193
|
+
const latestPromptID = promptID ?? prior?.latestPromptID;
|
|
194
|
+
|
|
195
|
+
const tracked =
|
|
196
|
+
event.kind === 'needs-input' ||
|
|
197
|
+
event.kind === 'prompt-submitted' ||
|
|
198
|
+
event.kind === 'turn-done'
|
|
199
|
+
? event.kind
|
|
200
|
+
: (prior?.lastKind ?? event.kind);
|
|
201
|
+
|
|
202
|
+
this.hookState.set(atcID, {
|
|
203
|
+
lastKind: tracked,
|
|
204
|
+
...(latestPromptID === undefined ? {} : { latestPromptID }),
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
return event;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function resolveGrokHome(): string {
|
|
212
|
+
const home = process.env['GROK_HOME'];
|
|
213
|
+
|
|
214
|
+
return home !== undefined && home !== '' ? home : join(homedir(), '.grok');
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function buildGrokNameSource(sessionID: string, cwd: string): string {
|
|
218
|
+
return join(resolveGrokHome(), 'sessions', encodeURIComponent(cwd), sessionID, 'summary.json');
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function buildTurnDoneEvent(
|
|
222
|
+
named: Readonly<AdapterEvent>,
|
|
223
|
+
payload: Readonly<Record<string, unknown>>,
|
|
224
|
+
): AdapterEvent {
|
|
225
|
+
const lastMessage = payload['lastAssistantMessage'];
|
|
226
|
+
|
|
227
|
+
return {
|
|
228
|
+
...named,
|
|
229
|
+
kind: 'turn-done',
|
|
230
|
+
...(typeof lastMessage === 'string' && lastMessage !== ''
|
|
231
|
+
? { detail: truncateDetail(lastMessage) }
|
|
232
|
+
: {}),
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function readSummary(source: string | null): Record<string, unknown> | null {
|
|
237
|
+
if (source === null) {
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
try {
|
|
242
|
+
const parsed: unknown = JSON.parse(readFileSync(source, 'utf8'));
|
|
243
|
+
|
|
244
|
+
return isRecord(parsed) ? parsed : null;
|
|
245
|
+
} catch {
|
|
246
|
+
return null;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function findGrokSummary(source: string): string | null {
|
|
251
|
+
const sessionID = source.split('/').at(-2);
|
|
252
|
+
|
|
253
|
+
if (sessionID === undefined || sessionID === '') {
|
|
254
|
+
return null;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const root = join(resolveGrokHome(), 'sessions');
|
|
258
|
+
|
|
259
|
+
try {
|
|
260
|
+
for (const group of readdirSync(root)) {
|
|
261
|
+
const candidate = join(root, group, sessionID, 'summary.json');
|
|
262
|
+
|
|
263
|
+
if (existsSync(candidate)) {
|
|
264
|
+
return candidate;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
} catch {}
|
|
268
|
+
|
|
269
|
+
return null;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function truncateDetail(text: string): string {
|
|
273
|
+
return text.length <= 600 ? text : `${text.slice(0, 599)}…`;
|
|
274
|
+
}
|
package/src/hook-report.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
|
+
import { normalizeHookEventName } from './normalize-hook-event';
|
|
1
2
|
import { isRecord, sendReport } from './report';
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
|
-
* Runs as a
|
|
5
|
-
*
|
|
6
|
-
*
|
|
5
|
+
* Runs as a hook inside wrangled sessions. Reads the hook event from stdin
|
|
6
|
+
* (Claude snake_case keys or Grok camelCase keys) and forwards a PascalCase
|
|
7
|
+
* event name to the atc unix socket. Always exits 0 so it never blocks the
|
|
8
|
+
* session it reports on.
|
|
7
9
|
*/
|
|
8
10
|
export async function runHookReport(): Promise<void> {
|
|
9
11
|
const sock = process.env['ATC_SOCKET'];
|
|
@@ -22,7 +24,9 @@ export async function runHookReport(): Promise<void> {
|
|
|
22
24
|
}
|
|
23
25
|
} catch {}
|
|
24
26
|
|
|
25
|
-
const
|
|
27
|
+
const rawName = payload['hook_event_name'] ?? payload['hookEventName'];
|
|
28
|
+
const event = typeof rawName === 'string' ? normalizeHookEventName(rawName) : rawName;
|
|
29
|
+
const line = `${JSON.stringify({ atcId, event, payload })}\n`;
|
|
26
30
|
|
|
27
31
|
await sendReport(sock, line, 2000);
|
|
28
32
|
}
|
package/src/index.ts
CHANGED
|
@@ -10,16 +10,25 @@ 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' | 'codex';
|
|
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
|
+
{ agent: 'codex' as const, label: 'Codex' },
|
|
30
|
+
];
|
|
31
|
+
|
|
23
32
|
interface MirrorSession {
|
|
24
33
|
id: string;
|
|
25
34
|
name: string;
|
|
@@ -31,9 +40,10 @@ interface MirrorSession {
|
|
|
31
40
|
unread: boolean;
|
|
32
41
|
lastMsg: string;
|
|
33
42
|
createdAt: number;
|
|
34
|
-
kind: 'pty' | '
|
|
43
|
+
kind: 'pty' | 'headless';
|
|
35
44
|
alive: boolean;
|
|
36
45
|
resumable: boolean;
|
|
46
|
+
agent: AgentKind;
|
|
37
47
|
}
|
|
38
48
|
|
|
39
49
|
let mode: Mode = 'home';
|
|
@@ -58,6 +68,8 @@ let pickerSelected = 0;
|
|
|
58
68
|
let spawnDir = '';
|
|
59
69
|
let spawnName = '';
|
|
60
70
|
let spawnResume = false;
|
|
71
|
+
let spawnAgent: AgentKind = 'claude';
|
|
72
|
+
let lastUsedAgent: AgentKind = 'claude';
|
|
61
73
|
const stdout = process.stdout;
|
|
62
74
|
|
|
63
75
|
// Full height: the atc status bar only exists on home/overlay screens;
|
|
@@ -251,7 +263,17 @@ function openOverlay() {
|
|
|
251
263
|
function renderPicker() {
|
|
252
264
|
const verb = spawnResume ? 'adopt' : 'spawn';
|
|
253
265
|
|
|
254
|
-
if (mode === 'picker-
|
|
266
|
+
if (mode === 'picker-agent') {
|
|
267
|
+
pickerSelected = Math.min(pickerSelected, AGENT_PICKS.length - 1);
|
|
268
|
+
|
|
269
|
+
drawPicker({
|
|
270
|
+
title: `${verb}: agent`,
|
|
271
|
+
items: AGENT_PICKS.map((pick) => pick.label),
|
|
272
|
+
selected: pickerSelected,
|
|
273
|
+
input: pickerInput,
|
|
274
|
+
hint: '↑↓ move · ⏎ select · esc cancel',
|
|
275
|
+
});
|
|
276
|
+
} else if (mode === 'picker-dir') {
|
|
255
277
|
const items = pickMatches(allDirs, pickerInput).map((d) => formatDir(d));
|
|
256
278
|
|
|
257
279
|
pickerSelected = Math.min(pickerSelected, Math.max(0, Math.min(items.length, 10) - 1));
|
|
@@ -295,9 +317,24 @@ function renderPicker() {
|
|
|
295
317
|
scheduleStatus();
|
|
296
318
|
}
|
|
297
319
|
|
|
298
|
-
|
|
320
|
+
function openAgentPicker(resume = false) {
|
|
299
321
|
spawnResume = resume;
|
|
322
|
+
spawnAgent = lastUsedAgent;
|
|
323
|
+
pickerInput = '';
|
|
324
|
+
|
|
325
|
+
pickerSelected = Math.max(
|
|
326
|
+
0,
|
|
327
|
+
AGENT_PICKS.findIndex((p) => p.agent === lastUsedAgent),
|
|
328
|
+
);
|
|
329
|
+
|
|
330
|
+
mode = 'picker-agent';
|
|
300
331
|
|
|
332
|
+
stdout.write(ansi.clear);
|
|
333
|
+
|
|
334
|
+
renderPicker();
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
async function openDirPicker() {
|
|
301
338
|
let recent: string[] = [];
|
|
302
339
|
|
|
303
340
|
try {
|
|
@@ -344,6 +381,7 @@ async function spawnFromPicker(prompt: string) {
|
|
|
344
381
|
cols: cols(),
|
|
345
382
|
rows: ptyRows(),
|
|
346
383
|
...(spawnResume ? { resume: true } : {}),
|
|
384
|
+
agent: spawnAgent,
|
|
347
385
|
});
|
|
348
386
|
|
|
349
387
|
const spawned = toMirrorSession(ok['session']);
|
|
@@ -407,9 +445,10 @@ function toMirrorSession(value: unknown): MirrorSession | null {
|
|
|
407
445
|
unread: value['unread'],
|
|
408
446
|
lastMsg: value['lastMsg'],
|
|
409
447
|
createdAt: value['createdAt'],
|
|
410
|
-
kind: value['kind'] === '
|
|
448
|
+
kind: value['kind'] === 'headless' ? 'headless' : 'pty',
|
|
411
449
|
alive: value['alive'],
|
|
412
|
-
resumable: typeof value['
|
|
450
|
+
resumable: typeof value['agentSessionID'] === 'string',
|
|
451
|
+
agent: value['agent'] === 'grok' || value['agent'] === 'codex' ? value['agent'] : 'claude',
|
|
413
452
|
};
|
|
414
453
|
}
|
|
415
454
|
|
|
@@ -429,6 +468,7 @@ function upsertMirror(d: Readonly<MirrorSession>) {
|
|
|
429
468
|
existing.kind = d.kind;
|
|
430
469
|
existing.alive = d.alive;
|
|
431
470
|
existing.resumable = d.resumable;
|
|
471
|
+
existing.agent = d.agent;
|
|
432
472
|
}
|
|
433
473
|
}
|
|
434
474
|
|
|
@@ -487,7 +527,7 @@ function applyDaemonEvent(e: EventMsg) {
|
|
|
487
527
|
s.alive = false;
|
|
488
528
|
}
|
|
489
529
|
|
|
490
|
-
if (e['kind'] === 'pty' || e['kind'] === '
|
|
530
|
+
if (e['kind'] === 'pty' || e['kind'] === 'headless') {
|
|
491
531
|
s.kind = e['kind'];
|
|
492
532
|
}
|
|
493
533
|
|
|
@@ -507,7 +547,15 @@ function applyDaemonEvent(e: EventMsg) {
|
|
|
507
547
|
s.lastMsg = e['lastMsg'];
|
|
508
548
|
}
|
|
509
549
|
|
|
510
|
-
if (
|
|
550
|
+
if (e['agent'] === 'grok' || e['agent'] === 'codex' || e['agent'] === 'claude') {
|
|
551
|
+
s.agent = e['agent'];
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
if (typeof e['agentSessionID'] === 'string') {
|
|
555
|
+
if (!s.resumable) {
|
|
556
|
+
lastUsedAgent = s.agent;
|
|
557
|
+
}
|
|
558
|
+
|
|
511
559
|
s.resumable = true;
|
|
512
560
|
}
|
|
513
561
|
}
|
|
@@ -735,7 +783,7 @@ function applyOverlayKey(buf: Buffer) {
|
|
|
735
783
|
}
|
|
736
784
|
|
|
737
785
|
if (ch === 'n') {
|
|
738
|
-
|
|
786
|
+
openAgentPicker();
|
|
739
787
|
|
|
740
788
|
return;
|
|
741
789
|
}
|
|
@@ -772,7 +820,13 @@ function applyOverlayKey(buf: Buffer) {
|
|
|
772
820
|
return;
|
|
773
821
|
}
|
|
774
822
|
|
|
775
|
-
if (
|
|
823
|
+
if (
|
|
824
|
+
ch === 'H' &&
|
|
825
|
+
sel !== undefined &&
|
|
826
|
+
sel.kind === 'pty' &&
|
|
827
|
+
sel.alive &&
|
|
828
|
+
sel.agent === 'claude'
|
|
829
|
+
) {
|
|
776
830
|
ejectTarget = sel.id;
|
|
777
831
|
pickerInput = '';
|
|
778
832
|
mode = 'picker-eject';
|
|
@@ -784,14 +838,14 @@ function applyOverlayKey(buf: Buffer) {
|
|
|
784
838
|
return;
|
|
785
839
|
}
|
|
786
840
|
|
|
787
|
-
if (ch === 'P' && sel !== undefined && sel.resumable && (sel.kind === '
|
|
841
|
+
if (ch === 'P' && sel !== undefined && sel.resumable && (sel.kind === 'headless' || !sel.alive)) {
|
|
788
842
|
void adoptSession(sel.id);
|
|
789
843
|
|
|
790
844
|
return;
|
|
791
845
|
}
|
|
792
846
|
|
|
793
847
|
if (ch === 'r') {
|
|
794
|
-
|
|
848
|
+
openAgentPicker(true);
|
|
795
849
|
|
|
796
850
|
return;
|
|
797
851
|
}
|
|
@@ -893,7 +947,7 @@ function applyTextKey(buf: Buffer, onSubmit: () => void, onCancel: () => void) {
|
|
|
893
947
|
return;
|
|
894
948
|
}
|
|
895
949
|
|
|
896
|
-
if (mode === 'picker-dir') {
|
|
950
|
+
if (mode === 'picker-dir' || mode === 'picker-agent') {
|
|
897
951
|
if (isDown(buf)) {
|
|
898
952
|
pickerSelected++;
|
|
899
953
|
|
|
@@ -933,6 +987,7 @@ const boot = await bootDaemonClient();
|
|
|
933
987
|
let client = boot.client;
|
|
934
988
|
let daemonStale = boot.stale;
|
|
935
989
|
|
|
990
|
+
lastUsedAgent = boot.lastUsedAgent;
|
|
936
991
|
client.onEvent = applyDaemonEvent;
|
|
937
992
|
|
|
938
993
|
await refreshMirror();
|
|
@@ -989,6 +1044,7 @@ async function restartDaemon() {
|
|
|
989
1044
|
|
|
990
1045
|
client = next.client;
|
|
991
1046
|
daemonStale = next.stale;
|
|
1047
|
+
lastUsedAgent = next.lastUsedAgent;
|
|
992
1048
|
client.onEvent = applyDaemonEvent;
|
|
993
1049
|
|
|
994
1050
|
await sendQuiet('fleet.restore', { cols: cols(), rows: ptyRows() });
|
|
@@ -1032,13 +1088,13 @@ process.stdin.on('data', (buf: Buffer) => {
|
|
|
1032
1088
|
const ch = buf.toString();
|
|
1033
1089
|
|
|
1034
1090
|
if (ch === 'n') {
|
|
1035
|
-
|
|
1091
|
+
openAgentPicker();
|
|
1036
1092
|
|
|
1037
1093
|
return;
|
|
1038
1094
|
}
|
|
1039
1095
|
|
|
1040
1096
|
if (ch === 'r') {
|
|
1041
|
-
|
|
1097
|
+
openAgentPicker(true);
|
|
1042
1098
|
|
|
1043
1099
|
return;
|
|
1044
1100
|
}
|
|
@@ -1065,6 +1121,20 @@ process.stdin.on('data', (buf: Buffer) => {
|
|
|
1065
1121
|
|
|
1066
1122
|
return;
|
|
1067
1123
|
}
|
|
1124
|
+
case 'picker-agent': {
|
|
1125
|
+
applyTextKey(
|
|
1126
|
+
buf,
|
|
1127
|
+
() => {
|
|
1128
|
+
spawnAgent = AGENT_PICKS[pickerSelected]?.agent ?? 'claude';
|
|
1129
|
+
void openDirPicker();
|
|
1130
|
+
},
|
|
1131
|
+
() => {
|
|
1132
|
+
toBase();
|
|
1133
|
+
},
|
|
1134
|
+
);
|
|
1135
|
+
|
|
1136
|
+
return;
|
|
1137
|
+
}
|
|
1068
1138
|
case 'picker-dir': {
|
|
1069
1139
|
applyTextKey(
|
|
1070
1140
|
buf,
|
|
@@ -1090,7 +1160,18 @@ process.stdin.on('data', (buf: Buffer) => {
|
|
|
1090
1160
|
renderPicker();
|
|
1091
1161
|
},
|
|
1092
1162
|
() => {
|
|
1093
|
-
|
|
1163
|
+
pickerInput = '';
|
|
1164
|
+
|
|
1165
|
+
pickerSelected = Math.max(
|
|
1166
|
+
0,
|
|
1167
|
+
AGENT_PICKS.findIndex((p) => p.agent === spawnAgent),
|
|
1168
|
+
);
|
|
1169
|
+
|
|
1170
|
+
mode = 'picker-agent';
|
|
1171
|
+
|
|
1172
|
+
stdout.write(ansi.clear);
|
|
1173
|
+
|
|
1174
|
+
renderPicker();
|
|
1094
1175
|
},
|
|
1095
1176
|
);
|
|
1096
1177
|
|
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, grok, or codex; 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', 'codex'],
|
|
56
|
+
description: 'Which agent CLI to spawn; defaults to claude',
|
|
57
|
+
},
|
|
53
58
|
},
|
|
54
59
|
required: ['cwd'],
|
|
55
60
|
additionalProperties: false,
|
|
@@ -236,10 +241,24 @@ 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 (
|
|
247
|
+
rawAgent !== undefined &&
|
|
248
|
+
rawAgent !== 'claude' &&
|
|
249
|
+
rawAgent !== 'grok' &&
|
|
250
|
+
rawAgent !== 'codex'
|
|
251
|
+
) {
|
|
252
|
+
throw new Error("agent must be 'claude', 'grok', or 'codex'");
|
|
253
|
+
}
|
|
254
|
+
|
|
239
255
|
const ok = await client.sendRequest('session.spawn', {
|
|
240
256
|
cwd: args['cwd'],
|
|
241
257
|
...(typeof args['name'] === 'string' ? { name: args['name'] } : {}),
|
|
242
258
|
...(typeof args['prompt'] === 'string' ? { prompt: args['prompt'] } : {}),
|
|
259
|
+
...(rawAgent === 'claude' || rawAgent === 'grok' || rawAgent === 'codex'
|
|
260
|
+
? { agent: rawAgent }
|
|
261
|
+
: {}),
|
|
243
262
|
cols: 100,
|
|
244
263
|
rows: 30,
|
|
245
264
|
});
|
|
@@ -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,26 @@
|
|
|
1
|
+
import { buildCLICommand } from './build-cli-command';
|
|
2
|
+
|
|
3
|
+
const CODEX_HOOK_EVENTS = [
|
|
4
|
+
'SessionStart',
|
|
5
|
+
'UserPromptSubmit',
|
|
6
|
+
'PermissionRequest',
|
|
7
|
+
'Stop',
|
|
8
|
+
] as const;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Print the Codex hook entries to stdout. The operator merges them into
|
|
12
|
+
* `$CODEX_HOME/hooks.json` and trusts them once in the Codex TUI — Codex
|
|
13
|
+
* parses untrusted hooks but never runs them. atc never writes that path.
|
|
14
|
+
*/
|
|
15
|
+
export function printCodexHookFile(): void {
|
|
16
|
+
process.stdout.write(buildCodexHookFile());
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function buildCodexHookFile(): string {
|
|
20
|
+
const cmd = buildCLICommand('hook-report');
|
|
21
|
+
const buildEntry = (timeout: number) => [{ hooks: [{ type: 'command', command: cmd, timeout }] }];
|
|
22
|
+
const hooks = Object.fromEntries(CODEX_HOOK_EVENTS.map((event) => [event, buildEntry(5)]));
|
|
23
|
+
|
|
24
|
+
// Codex caps SessionEnd hooks at three seconds.
|
|
25
|
+
return `${JSON.stringify({ hooks: { ...hooks, SessionEnd: buildEntry(3) } }, null, 2)}\n`;
|
|
26
|
+
}
|
|
@@ -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.
|