@zgeoff/atc 0.1.12 → 1.0.1
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 +50 -12
- package/package.json +7 -2
- package/src/{agent-adapter.ts → agents/agent-adapter.ts} +31 -14
- package/src/{build-cli-command.ts → agents/build-cli-command.ts} +1 -1
- package/src/agents/build-hook-settings.ts +59 -0
- package/src/{claude-adapter.ts → agents/claude-adapter.ts} +41 -80
- package/src/{codex-adapter.ts → agents/codex-adapter.ts} +41 -30
- package/src/agents/gateway-adapter.ts +106 -0
- package/src/{grok-adapter.ts → agents/grok-adapter.ts} +83 -46
- package/src/agents/resolve-agent-home.ts +13 -0
- package/src/agents/truncate-detail.ts +7 -0
- package/src/agents/write-hook-settings.ts +37 -0
- package/src/cli.ts +25 -14
- package/src/{boot-daemon.ts → client/boot-daemon.ts} +8 -7
- package/src/client/build-client-machine.ts +119 -0
- package/src/client/collect-agent-picks.ts +31 -0
- package/src/{daemon-client.ts → client/daemon-client.ts} +4 -4
- package/src/{dirs.ts → client/dirs.ts} +8 -0
- package/src/client/format-overlay-agent-mark.ts +15 -0
- package/src/{index.ts → client/index.ts} +181 -550
- package/src/client/keys.ts +111 -0
- package/src/client/parse-daemon-event.ts +114 -0
- package/src/{pick-tab-target.ts → client/pick-tab-target.ts} +2 -2
- package/src/client/plan-vacated-rows.ts +26 -0
- package/src/client/spawn-picker.ts +305 -0
- package/src/client/to-mirror-session.ts +65 -0
- package/src/{ui.ts → client/ui.ts} +27 -8
- package/src/{attach-registry.ts → daemon/attach-registry.ts} +12 -10
- package/src/daemon/build-session-event.ts +43 -0
- package/src/{daemon-connection.ts → daemon/daemon-connection.ts} +194 -108
- package/src/{daemon.ts → daemon/daemon.ts} +148 -313
- package/src/{hooks.ts → daemon/hooks.ts} +6 -4
- package/src/daemon/mint-session-id.ts +12 -0
- package/src/{permission-registry.ts → daemon/permission-registry.ts} +5 -3
- package/src/daemon/restore-fleet.ts +141 -0
- package/src/daemon/run-eject-handoff.ts +33 -0
- package/src/{screen-model.ts → daemon/screen-model.ts} +1 -1
- package/src/daemon/session-runtime.ts +64 -0
- package/src/{sessions.ts → daemon/sessions.ts} +77 -133
- package/src/{start-headless-run.ts → daemon/start-headless-run.ts} +9 -3
- package/src/daemon/start-headless-turn.ts +57 -0
- package/src/hook-report.ts +2 -2
- package/src/mcp-server.ts +33 -44
- package/src/protocol/parse-request-params.ts +31 -0
- package/src/{protocol.ts → protocol/protocol.ts} +1 -1
- package/src/protocol/request-param-schemas.ts +109 -0
- package/src/shared/agent-session-id.ts +8 -0
- package/src/shared/build-optional-boolean.ts +10 -0
- package/src/shared/build-optional-string-array.ts +14 -0
- package/src/shared/build-optional-string.ts +10 -0
- package/src/shared/collect-gateways.ts +113 -0
- package/src/{config.ts → shared/config.ts} +47 -31
- package/src/{get-build.ts → shared/get-build.ts} +21 -6
- package/src/shared/get-record.ts +20 -0
- package/src/shared/session-id.ts +8 -0
- package/src/shared/to-agent-session-id.ts +12 -0
- package/src/shared/to-session-id.ts +11 -0
- package/src/shared/to-shell-arg.ts +7 -0
- package/src/statusline.ts +2 -2
- package/src/store/bun-sqlite-driver.ts +96 -0
- package/src/store/fleet-entry.ts +72 -0
- package/src/store/run-migrations.ts +242 -0
- package/src/store/state-store.ts +226 -0
- package/src/format-overlay-agent-mark.ts +0 -17
- package/src/state-store.ts +0 -236
- /package/src/{normalize-hook-event.ts → agents/normalize-hook-event.ts} +0 -0
- /package/src/{print-codex-hook-file.ts → agents/print-codex-hook-file.ts} +0 -0
- /package/src/{print-grok-hook-file.ts → agents/print-grok-hook-file.ts} +0 -0
- /package/src/{build-leader-chords.ts → client/build-leader-chords.ts} +0 -0
- /package/src/{daemon-error.ts → protocol/daemon-error.ts} +0 -0
- /package/src/{outbound-queue.ts → protocol/outbound-queue.ts} +0 -0
- /package/src/{collect-clean-env.ts → shared/collect-clean-env.ts} +0 -0
- /package/src/{report.ts → shared/report.ts} +0 -0
- /package/src/{reset-input-modes.ts → shared/reset-input-modes.ts} +0 -0
- /package/src/{resolve-repo-root.ts → shared/resolve-repo-root.ts} +0 -0
|
@@ -1,14 +1,15 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { join } from 'node:path';
|
|
1
|
+
import { writeFileSync } from 'node:fs';
|
|
3
2
|
import { spawn } from 'bun-pty';
|
|
4
3
|
import type { IPty } from 'bun-pty';
|
|
5
|
-
import {
|
|
6
|
-
import type {
|
|
7
|
-
import { collectCleanEnv } from '
|
|
8
|
-
import { socketPath,
|
|
4
|
+
import type { AdapterEvent, AgentAdapter, AgentID } from '../agents/agent-adapter';
|
|
5
|
+
import type { AgentSessionID } from '../shared/agent-session-id';
|
|
6
|
+
import { collectCleanEnv } from '../shared/collect-clean-env';
|
|
7
|
+
import { socketPath, statusFile } from '../shared/config';
|
|
8
|
+
import { resolveRepoRoot } from '../shared/resolve-repo-root';
|
|
9
|
+
import type { SessionID } from '../shared/session-id';
|
|
10
|
+
import type { FleetEntry, FleetStore } from '../store/fleet-entry';
|
|
9
11
|
import type { HookEvent } from './hooks';
|
|
10
|
-
import {
|
|
11
|
-
import { resolveRepoRoot } from './resolve-repo-root';
|
|
12
|
+
import { mintSessionID } from './mint-session-id';
|
|
12
13
|
|
|
13
14
|
export type SessionState = 'running' | 'needs_you' | 'done' | 'exited';
|
|
14
15
|
|
|
@@ -17,15 +18,15 @@ export type SessionEventKind = 'added' | 'state' | 'renamed' | 'removed';
|
|
|
17
18
|
// The over-the-wire view of a session: everything but the PTY handle, plus
|
|
18
19
|
// the surface kind.
|
|
19
20
|
export interface SessionDescriptor {
|
|
20
|
-
readonly id:
|
|
21
|
+
readonly id: SessionID;
|
|
21
22
|
readonly name: string;
|
|
22
23
|
readonly cwd: string;
|
|
23
24
|
readonly state: SessionState;
|
|
24
25
|
readonly unread: boolean;
|
|
25
26
|
readonly lastMsg: string;
|
|
26
27
|
readonly lastDetail?: string;
|
|
27
|
-
readonly agentSessionID?:
|
|
28
|
-
readonly agent:
|
|
28
|
+
readonly agentSessionID?: AgentSessionID;
|
|
29
|
+
readonly agent: AgentID;
|
|
29
30
|
readonly pinned: boolean;
|
|
30
31
|
readonly lastAttachedAt: number;
|
|
31
32
|
readonly repoRoot: string;
|
|
@@ -33,10 +34,14 @@ export interface SessionDescriptor {
|
|
|
33
34
|
readonly createdAt: number;
|
|
34
35
|
readonly kind: 'pty' | 'headless';
|
|
35
36
|
readonly alive: boolean;
|
|
37
|
+
|
|
38
|
+
// Whether this session's agent can run a headless turn, so the client can
|
|
39
|
+
// offer the eject action without knowing which agent it is.
|
|
40
|
+
readonly canEject: boolean;
|
|
36
41
|
}
|
|
37
42
|
|
|
38
43
|
export interface Session {
|
|
39
|
-
id:
|
|
44
|
+
id: SessionID;
|
|
40
45
|
name: string;
|
|
41
46
|
cwd: string;
|
|
42
47
|
kind: 'pty' | 'headless';
|
|
@@ -45,8 +50,8 @@ export interface Session {
|
|
|
45
50
|
unread: boolean;
|
|
46
51
|
lastMsg: string;
|
|
47
52
|
lastDetail?: string;
|
|
48
|
-
agentSessionID?:
|
|
49
|
-
agent:
|
|
53
|
+
agentSessionID?: AgentSessionID;
|
|
54
|
+
agent: AgentID;
|
|
50
55
|
transcriptSource?: string;
|
|
51
56
|
pinned: boolean;
|
|
52
57
|
|
|
@@ -64,86 +69,10 @@ export interface Session {
|
|
|
64
69
|
createdAt: number;
|
|
65
70
|
}
|
|
66
71
|
|
|
67
|
-
let counter = 0;
|
|
68
|
-
|
|
69
|
-
export interface FleetEntry {
|
|
70
|
-
readonly name: string;
|
|
71
|
-
readonly cwd: string;
|
|
72
|
-
readonly agentSessionID: string;
|
|
73
|
-
readonly agent: AgentKind;
|
|
74
|
-
readonly pinned?: boolean;
|
|
75
|
-
readonly lastAttachedAt?: number;
|
|
76
|
-
readonly exited?: boolean;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
const fleetFile = join(stateDir, 'fleet.json');
|
|
80
|
-
|
|
81
|
-
export interface FleetStore {
|
|
82
|
-
readonly loadFleet: () => FleetEntry[];
|
|
83
|
-
readonly writeFleet: (entries: readonly FleetEntry[]) => void;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
const jsonFleetStore: FleetStore = {
|
|
87
|
-
loadFleet: () => loadFleet(),
|
|
88
|
-
writeFleet: (entries) => {
|
|
89
|
-
try {
|
|
90
|
-
writeFileSync(fleetFile, JSON.stringify(entries, null, 2));
|
|
91
|
-
} catch {}
|
|
92
|
-
},
|
|
93
|
-
};
|
|
94
|
-
|
|
95
|
-
export function parseFleetEntry(raw: unknown): FleetEntry | undefined {
|
|
96
|
-
if (!isRecord(raw) || typeof raw['name'] !== 'string' || typeof raw['cwd'] !== 'string') {
|
|
97
|
-
return undefined;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
// Fleet files written before the id key was agent-neutral carry it under
|
|
101
|
-
// its Claude-era name.
|
|
102
|
-
const agentSessionID = raw['agentSessionID'] ?? raw['claudeId'];
|
|
103
|
-
|
|
104
|
-
if (typeof agentSessionID !== 'string') {
|
|
105
|
-
return undefined;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
return {
|
|
109
|
-
name: raw['name'],
|
|
110
|
-
cwd: raw['cwd'],
|
|
111
|
-
agentSessionID,
|
|
112
|
-
agent: toAgentKind(raw['agent']),
|
|
113
|
-
...(raw['pinned'] === true ? { pinned: true } : {}),
|
|
114
|
-
...(typeof raw['lastAttachedAt'] === 'number' ? { lastAttachedAt: raw['lastAttachedAt'] } : {}),
|
|
115
|
-
...(raw['exited'] === true ? { exited: true } : {}),
|
|
116
|
-
};
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
function loadFleet(): FleetEntry[] {
|
|
120
|
-
try {
|
|
121
|
-
const parsed: unknown = JSON.parse(readFileSync(fleetFile, 'utf8'));
|
|
122
|
-
|
|
123
|
-
if (!Array.isArray(parsed)) {
|
|
124
|
-
return [];
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
const entries: FleetEntry[] = [];
|
|
128
|
-
|
|
129
|
-
for (const entry of parsed) {
|
|
130
|
-
const parsedEntry = parseFleetEntry(entry);
|
|
131
|
-
|
|
132
|
-
if (parsedEntry !== undefined) {
|
|
133
|
-
entries.push(parsedEntry);
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
return entries;
|
|
138
|
-
} catch {
|
|
139
|
-
return [];
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
|
|
143
72
|
export class SessionManager {
|
|
144
73
|
sessions: Session[] = [];
|
|
145
74
|
|
|
146
|
-
focusedId:
|
|
75
|
+
focusedId: SessionID | null = null;
|
|
147
76
|
|
|
148
77
|
onOutput: (s: Session, data: string) => void = () => {};
|
|
149
78
|
|
|
@@ -151,9 +80,12 @@ export class SessionManager {
|
|
|
151
80
|
|
|
152
81
|
onEvent: (kind: SessionEventKind, s: Session) => void = () => {};
|
|
153
82
|
|
|
154
|
-
|
|
83
|
+
// Whether any registered adapter has a screen detector, decided once at
|
|
84
|
+
// construction since the registry never changes afterward. Lets a hot path
|
|
85
|
+
// that only cares about this answer skip walking live sessions to find it.
|
|
86
|
+
readonly hasScreenDetector: boolean;
|
|
155
87
|
|
|
156
|
-
private readonly adapters:
|
|
88
|
+
private readonly adapters: Readonly<Record<AgentID, AgentAdapter>>;
|
|
157
89
|
|
|
158
90
|
private readonly store: FleetStore;
|
|
159
91
|
|
|
@@ -161,31 +93,30 @@ export class SessionManager {
|
|
|
161
93
|
|
|
162
94
|
constructor(
|
|
163
95
|
fallback: AgentAdapter,
|
|
164
|
-
store: FleetStore
|
|
96
|
+
store: FleetStore,
|
|
165
97
|
statusPath: string | undefined = statusFile,
|
|
166
|
-
adapters:
|
|
98
|
+
adapters: readonly AgentAdapter[] = [],
|
|
167
99
|
) {
|
|
168
|
-
|
|
169
|
-
|
|
100
|
+
// Each adapter names the id it answers to, so a registry key can never
|
|
101
|
+
// disagree with the adapter behind it. A later one wins the id.
|
|
102
|
+
this.adapters = Object.fromEntries([fallback, ...adapters].map((a) => [a.id, a]));
|
|
103
|
+
this.hasScreenDetector = Object.values(this.adapters).some((a) => a.screenDetector !== null);
|
|
170
104
|
this.store = store;
|
|
171
105
|
this.statusPath = statusPath ?? statusFile;
|
|
172
106
|
}
|
|
173
107
|
|
|
174
108
|
/**
|
|
175
|
-
* Adapter
|
|
176
|
-
*
|
|
109
|
+
* Adapter registered under this id, or null when there is none. Lookup
|
|
110
|
+
* never falls back to another id: a session whose agent is not registered
|
|
111
|
+
* is unsupported, not somebody else's spawn.
|
|
177
112
|
*/
|
|
178
|
-
findAdapter(
|
|
179
|
-
|
|
180
|
-
return this.adapters.claude ?? this.fallback;
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
return this.adapters[kind] ?? null;
|
|
113
|
+
findAdapter(id: AgentID): AgentAdapter | null {
|
|
114
|
+
return this.adapters[id] ?? null;
|
|
184
115
|
}
|
|
185
116
|
|
|
186
117
|
// Hands a live terminal session off to a headless run: the terminal dies,
|
|
187
118
|
// the record lives on as a headless session and keeps its screen history.
|
|
188
|
-
yankHeadless(id:
|
|
119
|
+
yankHeadless(id: SessionID): Session | null {
|
|
189
120
|
const s = this.sessions.find((x) => x.id === id);
|
|
190
121
|
|
|
191
122
|
if (!s || s.pty === null || s.agentSessionID === undefined) {
|
|
@@ -213,15 +144,26 @@ export class SessionManager {
|
|
|
213
144
|
restore(entry: FleetEntry): Session {
|
|
214
145
|
const exited = entry.exited === true;
|
|
215
146
|
|
|
147
|
+
// An entry whose agent is no longer registered still gets its row, so a
|
|
148
|
+
// backend dropped from the config shows as itself instead of vanishing or
|
|
149
|
+
// reviving under another agent. Adopting a terminal for it is refused.
|
|
150
|
+
let lastMsg = 'waiting to restore';
|
|
151
|
+
|
|
152
|
+
if (exited) {
|
|
153
|
+
lastMsg = 'killed';
|
|
154
|
+
} else if (this.findAdapter(entry.agent) === null) {
|
|
155
|
+
lastMsg = `no adapter for '${entry.agent}'`;
|
|
156
|
+
}
|
|
157
|
+
|
|
216
158
|
const session: Session = {
|
|
217
|
-
id:
|
|
159
|
+
id: mintSessionID(),
|
|
218
160
|
name: entry.name,
|
|
219
161
|
cwd: entry.cwd,
|
|
220
162
|
kind: 'headless',
|
|
221
163
|
pty: null,
|
|
222
164
|
state: exited ? 'exited' : 'running',
|
|
223
165
|
unread: false,
|
|
224
|
-
lastMsg
|
|
166
|
+
lastMsg,
|
|
225
167
|
agentSessionID: entry.agentSessionID,
|
|
226
168
|
agent: entry.agent,
|
|
227
169
|
pinned: entry.pinned ?? false,
|
|
@@ -240,7 +182,7 @@ export class SessionManager {
|
|
|
240
182
|
|
|
241
183
|
// Adopts a headless session back into a terminal: a fresh PTY resumes the
|
|
242
184
|
// same agent session id.
|
|
243
|
-
adoptTerminal(id:
|
|
185
|
+
adoptTerminal(id: SessionID, cols: number, rows: number): Session | null {
|
|
244
186
|
const s = this.sessions.find((x) => x.id === id);
|
|
245
187
|
|
|
246
188
|
if (!s || s.pty !== null || s.agentSessionID === undefined) {
|
|
@@ -285,7 +227,7 @@ export class SessionManager {
|
|
|
285
227
|
this.emitChange();
|
|
286
228
|
});
|
|
287
229
|
|
|
288
|
-
this.writeFleet();
|
|
230
|
+
void this.writeFleet();
|
|
289
231
|
this.onEvent('state', s);
|
|
290
232
|
this.emitChange();
|
|
291
233
|
|
|
@@ -297,7 +239,7 @@ export class SessionManager {
|
|
|
297
239
|
* user strength, so auto-summaries stop overwriting it while an
|
|
298
240
|
* in-session rename still wins. Pinned sessions lead every list.
|
|
299
241
|
*/
|
|
300
|
-
updateSession(id:
|
|
242
|
+
updateSession(id: SessionID, name?: string, pinned?: boolean): boolean {
|
|
301
243
|
const s = this.sessions.find((x) => x.id === id);
|
|
302
244
|
|
|
303
245
|
if (s === undefined) {
|
|
@@ -317,14 +259,14 @@ export class SessionManager {
|
|
|
317
259
|
this.onEvent('state', s);
|
|
318
260
|
}
|
|
319
261
|
|
|
320
|
-
this.writeFleet();
|
|
262
|
+
void this.writeFleet();
|
|
321
263
|
this.writeStatus();
|
|
322
264
|
this.emitChange();
|
|
323
265
|
|
|
324
266
|
return true;
|
|
325
267
|
}
|
|
326
268
|
|
|
327
|
-
updateSurfaceState(id:
|
|
269
|
+
updateSurfaceState(id: SessionID, state: SessionState, msg: string) {
|
|
328
270
|
const s = this.sessions.find((x) => x.id === id);
|
|
329
271
|
|
|
330
272
|
if (!s || s.kind !== 'headless') {
|
|
@@ -341,7 +283,7 @@ export class SessionManager {
|
|
|
341
283
|
|
|
342
284
|
// The detector-stack screen tier reports through here: only flips between
|
|
343
285
|
// running and needs_you — hook-driven done/exited states always win.
|
|
344
|
-
updateAttention(id:
|
|
286
|
+
updateAttention(id: SessionID, state: 'needs_you' | 'running', msg: string) {
|
|
345
287
|
const s = this.sessions.find((x) => x.id === id);
|
|
346
288
|
|
|
347
289
|
if (!s || s.pty === null || s.state === state) {
|
|
@@ -367,17 +309,17 @@ export class SessionManager {
|
|
|
367
309
|
return this.sessions.find((s) => s.id === this.focusedId) ?? null;
|
|
368
310
|
}
|
|
369
311
|
|
|
370
|
-
// resume: true opens the agent's own session picker;
|
|
371
|
-
// specific
|
|
312
|
+
// resume: true opens the agent's own session picker; an agent session id
|
|
313
|
+
// resumes that specific session (fleet restore).
|
|
372
314
|
spawn(
|
|
373
315
|
cwd: string,
|
|
374
316
|
name: string,
|
|
375
317
|
prompt: string,
|
|
376
318
|
cols: number,
|
|
377
319
|
rows: number,
|
|
378
|
-
resume: boolean |
|
|
320
|
+
resume: boolean | AgentSessionID = false,
|
|
379
321
|
namedBy: 'user' | 'auto' = 'auto',
|
|
380
|
-
agent:
|
|
322
|
+
agent: AgentID = 'claude',
|
|
381
323
|
): Session {
|
|
382
324
|
const adapter = this.findAdapter(agent);
|
|
383
325
|
|
|
@@ -385,7 +327,7 @@ export class SessionManager {
|
|
|
385
327
|
throw new Error(`no adapter for agent '${agent}'`);
|
|
386
328
|
}
|
|
387
329
|
|
|
388
|
-
const id =
|
|
330
|
+
const id = mintSessionID();
|
|
389
331
|
const plan = adapter.planSpawn({ prompt, resume });
|
|
390
332
|
|
|
391
333
|
const pty = spawn(plan.bin, plan.args, {
|
|
@@ -440,7 +382,7 @@ export class SessionManager {
|
|
|
440
382
|
});
|
|
441
383
|
|
|
442
384
|
this.sessions.push(session);
|
|
443
|
-
this.writeFleet();
|
|
385
|
+
void this.writeFleet();
|
|
444
386
|
this.writeStatus();
|
|
445
387
|
this.onEvent('added', session);
|
|
446
388
|
|
|
@@ -465,6 +407,7 @@ export class SessionManager {
|
|
|
465
407
|
createdAt: s.createdAt,
|
|
466
408
|
kind: s.kind,
|
|
467
409
|
alive: s.pty !== null || (s.kind === 'headless' && s.state !== 'exited'),
|
|
410
|
+
canEject: (this.findAdapter(s.agent)?.headlessRunner ?? null) !== null,
|
|
468
411
|
}));
|
|
469
412
|
}
|
|
470
413
|
|
|
@@ -496,9 +439,7 @@ export class SessionManager {
|
|
|
496
439
|
|
|
497
440
|
if (ev.agentSessionID !== undefined && s.agentSessionID !== ev.agentSessionID) {
|
|
498
441
|
s.agentSessionID = ev.agentSessionID;
|
|
499
|
-
|
|
500
|
-
this.writeFleet();
|
|
501
|
-
|
|
442
|
+
void this.writeFleet();
|
|
502
443
|
dirty = true;
|
|
503
444
|
}
|
|
504
445
|
|
|
@@ -592,13 +533,13 @@ export class SessionManager {
|
|
|
592
533
|
s.namedBy = update.namedBy;
|
|
593
534
|
}
|
|
594
535
|
|
|
595
|
-
this.writeFleet();
|
|
536
|
+
void this.writeFleet();
|
|
596
537
|
this.onEvent('renamed', s);
|
|
597
538
|
this.emitChange();
|
|
598
539
|
}
|
|
599
540
|
|
|
600
541
|
// Shell command that re-opens this session outside atc (or anywhere).
|
|
601
|
-
buildResumeCommand(id:
|
|
542
|
+
buildResumeCommand(id: SessionID): string | null {
|
|
602
543
|
const s = this.sessions.find((x) => x.id === id);
|
|
603
544
|
|
|
604
545
|
if (!s) {
|
|
@@ -608,7 +549,7 @@ export class SessionManager {
|
|
|
608
549
|
return this.findAdapter(s.agent)?.buildResumeCommand(s.cwd, s.agentSessionID) ?? null;
|
|
609
550
|
}
|
|
610
551
|
|
|
611
|
-
attach(id:
|
|
552
|
+
attach(id: SessionID) {
|
|
612
553
|
const s = this.sessions.find((x) => x.id === id);
|
|
613
554
|
|
|
614
555
|
if (!s) {
|
|
@@ -617,8 +558,7 @@ export class SessionManager {
|
|
|
617
558
|
|
|
618
559
|
s.unread = false;
|
|
619
560
|
s.lastAttachedAt = Date.now();
|
|
620
|
-
|
|
621
|
-
this.writeFleet();
|
|
561
|
+
void this.writeFleet();
|
|
622
562
|
|
|
623
563
|
// Attaching answers the attention request: a still-pending prompt
|
|
624
564
|
// re-flags it via the next notification.
|
|
@@ -631,7 +571,7 @@ export class SessionManager {
|
|
|
631
571
|
this.emitChange();
|
|
632
572
|
}
|
|
633
573
|
|
|
634
|
-
ack(id:
|
|
574
|
+
ack(id: SessionID) {
|
|
635
575
|
const s = this.sessions.find((x) => x.id === id);
|
|
636
576
|
|
|
637
577
|
if (s) {
|
|
@@ -643,7 +583,10 @@ export class SessionManager {
|
|
|
643
583
|
this.emitChange();
|
|
644
584
|
}
|
|
645
585
|
|
|
646
|
-
kill
|
|
586
|
+
// A kill's response is the caller's cue that the session is safely
|
|
587
|
+
// archived, so the write it depends on must land before that response
|
|
588
|
+
// goes out.
|
|
589
|
+
async kill(id: SessionID): Promise<void> {
|
|
647
590
|
const s = this.sessions.find((x) => x.id === id);
|
|
648
591
|
|
|
649
592
|
if (!s) {
|
|
@@ -668,7 +611,8 @@ export class SessionManager {
|
|
|
668
611
|
this.onEvent('removed', s);
|
|
669
612
|
}
|
|
670
613
|
|
|
671
|
-
this.writeFleet();
|
|
614
|
+
await this.writeFleet();
|
|
615
|
+
|
|
672
616
|
this.emitChange();
|
|
673
617
|
}
|
|
674
618
|
|
|
@@ -699,7 +643,7 @@ export class SessionManager {
|
|
|
699
643
|
// so the last known fleet survives for `R` restore. Sessions whose
|
|
700
644
|
// terminal is gone persist as exited entries, so the killed archive
|
|
701
645
|
// survives a daemon restart too.
|
|
702
|
-
writeFleet() {
|
|
646
|
+
async writeFleet(): Promise<void> {
|
|
703
647
|
const fleet: FleetEntry[] = [];
|
|
704
648
|
|
|
705
649
|
for (const s of this.sessions) {
|
|
@@ -720,7 +664,7 @@ export class SessionManager {
|
|
|
720
664
|
});
|
|
721
665
|
}
|
|
722
666
|
|
|
723
|
-
this.store.writeFleet(fleet);
|
|
667
|
+
await this.store.writeFleet(fleet);
|
|
724
668
|
}
|
|
725
669
|
|
|
726
670
|
countStates() {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { query } from '@anthropic-ai/claude-agent-sdk';
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import type { AgentSessionID } from '../shared/agent-session-id';
|
|
3
|
+
import { collectCleanEnv } from '../shared/collect-clean-env';
|
|
4
|
+
import { isRecord } from '../shared/report';
|
|
4
5
|
|
|
5
6
|
const PERMISSION_MODES = [
|
|
6
7
|
'default',
|
|
@@ -14,8 +15,9 @@ const PERMISSION_MODES = [
|
|
|
14
15
|
interface HeadlessRunOptions {
|
|
15
16
|
readonly cwd: string;
|
|
16
17
|
readonly prompt: string;
|
|
17
|
-
readonly resume?:
|
|
18
|
+
readonly resume?: AgentSessionID;
|
|
18
19
|
readonly permissionMode?: string;
|
|
20
|
+
readonly settings?: string;
|
|
19
21
|
}
|
|
20
22
|
|
|
21
23
|
interface HeadlessRunHooks {
|
|
@@ -56,6 +58,10 @@ export function startHeadlessRun(
|
|
|
56
58
|
},
|
|
57
59
|
...(opts.resume === undefined ? {} : { resume: opts.resume }),
|
|
58
60
|
...(mode === undefined ? {} : { permissionMode: mode }),
|
|
61
|
+
|
|
62
|
+
// The same generated file the terminal spawn passes, so the turn
|
|
63
|
+
// runs against the session's own backend and instrumentation.
|
|
64
|
+
...(opts.settings === undefined ? {} : { extraArgs: { settings: opts.settings } }),
|
|
59
65
|
},
|
|
60
66
|
});
|
|
61
67
|
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { SessionID } from '../shared/session-id';
|
|
2
|
+
import type { SessionRuntime } from './session-runtime';
|
|
3
|
+
import type { SessionManager } from './sessions';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Starts one headless turn for a session: finds its adapter's headless
|
|
7
|
+
* runner, wires the run's output and completion into the session manager,
|
|
8
|
+
* and records the live handle on the session's runtime. Returns false
|
|
9
|
+
* without starting anything when the session is unknown, its agent has no
|
|
10
|
+
* headless runner, or a turn is already running.
|
|
11
|
+
*/
|
|
12
|
+
export function startHeadlessTurn(
|
|
13
|
+
mgr: SessionManager,
|
|
14
|
+
findRuntime: (sessionID: SessionID) => SessionRuntime | undefined,
|
|
15
|
+
sessionID: SessionID,
|
|
16
|
+
prompt: string,
|
|
17
|
+
): boolean {
|
|
18
|
+
const s = mgr.sessions.find((x) => x.id === sessionID);
|
|
19
|
+
const runner = s === undefined ? null : (mgr.findAdapter(s.agent)?.headlessRunner ?? null);
|
|
20
|
+
const runtime = findRuntime(sessionID);
|
|
21
|
+
|
|
22
|
+
if (s === undefined || runner === null || runtime === undefined || runtime.headlessRun !== null) {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
mgr.updateSurfaceState(sessionID, 'running', 'headless turn running');
|
|
27
|
+
|
|
28
|
+
const handle = runner(
|
|
29
|
+
{
|
|
30
|
+
cwd: s.cwd,
|
|
31
|
+
prompt,
|
|
32
|
+
...(s.agentSessionID === undefined ? {} : { resume: s.agentSessionID }),
|
|
33
|
+
permissionMode: 'auto',
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
onOutput: (text) => {
|
|
37
|
+
mgr.onOutput(s, text);
|
|
38
|
+
},
|
|
39
|
+
onDone: (summary) => {
|
|
40
|
+
runtime.headlessRun = null;
|
|
41
|
+
|
|
42
|
+
const doneMsg = summary === '' ? 'headless turn done' : summary;
|
|
43
|
+
|
|
44
|
+
mgr.updateSurfaceState(sessionID, 'done', doneMsg);
|
|
45
|
+
},
|
|
46
|
+
onNeedsYou: (msg) => {
|
|
47
|
+
runtime.headlessRun = null;
|
|
48
|
+
|
|
49
|
+
mgr.updateSurfaceState(sessionID, 'needs_you', msg);
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
runtime.headlessRun = handle;
|
|
55
|
+
|
|
56
|
+
return true;
|
|
57
|
+
}
|
package/src/hook-report.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { normalizeHookEventName } from './normalize-hook-event';
|
|
2
|
-
import { isRecord, sendReport } from './report';
|
|
1
|
+
import { normalizeHookEventName } from './agents/normalize-hook-event';
|
|
2
|
+
import { isRecord, sendReport } from './shared/report';
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Runs as a hook inside wrangled sessions. Reads the hook event from stdin
|
package/src/mcp-server.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { bootDaemonClient } from './client/boot-daemon';
|
|
3
|
+
import { DaemonError } from './protocol/daemon-error';
|
|
4
|
+
import { REQUEST_PARAM_SCHEMAS } from './protocol/request-param-schemas';
|
|
5
|
+
import { isRecord } from './shared/report';
|
|
4
6
|
|
|
5
7
|
// The slice of the daemon client the tool handlers need.
|
|
6
8
|
interface FleetCaller {
|
|
@@ -18,20 +20,32 @@ interface MCPTool {
|
|
|
18
20
|
readonly inputSchema: Readonly<Record<string, unknown>>;
|
|
19
21
|
}
|
|
20
22
|
|
|
21
|
-
const NO_INPUT: Readonly<Record<string, unknown>> = {
|
|
22
|
-
type: 'object',
|
|
23
|
-
properties: {},
|
|
24
|
-
additionalProperties: false,
|
|
25
|
-
};
|
|
23
|
+
const NO_INPUT: Readonly<Record<string, unknown>> = z.toJSONSchema(z.strictObject({}));
|
|
26
24
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
25
|
+
/**
|
|
26
|
+
* The session id shape MCP tool schemas require: present and described.
|
|
27
|
+
* The wire request schemas below share a defaulted-session shape instead,
|
|
28
|
+
* since they tolerate an absent session by defaulting it to an empty
|
|
29
|
+
* string.
|
|
30
|
+
*/
|
|
31
|
+
const SESSION_ID_BASE = z.object({
|
|
32
|
+
session: z.string().describe('The atc session id, from atc_session_list'),
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const SESSION_INPUT: Readonly<Record<string, unknown>> = z.toJSONSchema(SESSION_ID_BASE.strict());
|
|
36
|
+
const SPAWN_SCHEMA = REQUEST_PARAM_SCHEMAS['session.spawn'];
|
|
37
|
+
|
|
38
|
+
const SPAWN_INPUT: Readonly<Record<string, unknown>> = z.toJSONSchema(
|
|
39
|
+
z.strictObject({
|
|
40
|
+
cwd: SPAWN_SCHEMA.shape.cwd.describe('Absolute path of the working directory'),
|
|
41
|
+
name: SPAWN_SCHEMA.shape.name.describe('Session name; defaults to the directory basename'),
|
|
42
|
+
prompt: SPAWN_SCHEMA.shape.prompt.describe('First message for the session'),
|
|
43
|
+
agent: SPAWN_SCHEMA.shape.agent.describe(
|
|
44
|
+
'Which registered agent id to spawn; defaults to claude',
|
|
45
|
+
),
|
|
46
|
+
}),
|
|
47
|
+
{ io: 'input' },
|
|
48
|
+
);
|
|
35
49
|
|
|
36
50
|
const TOOLS: readonly MCPTool[] = [
|
|
37
51
|
{
|
|
@@ -43,22 +57,8 @@ const TOOLS: readonly MCPTool[] = [
|
|
|
43
57
|
{
|
|
44
58
|
name: 'atc_session_spawn',
|
|
45
59
|
description:
|
|
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
|
-
inputSchema:
|
|
48
|
-
type: 'object',
|
|
49
|
-
properties: {
|
|
50
|
-
cwd: { type: 'string', description: 'Absolute path of the working directory' },
|
|
51
|
-
name: { type: 'string', description: 'Session name; defaults to the directory basename' },
|
|
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
|
-
},
|
|
58
|
-
},
|
|
59
|
-
required: ['cwd'],
|
|
60
|
-
additionalProperties: false,
|
|
61
|
-
},
|
|
60
|
+
'Spawn a new session in a directory. Optional agent is an agent id the daemon has registered, such as claude, grok, or codex; omitted agent is always Claude, never the TUI last-used value. An unregistered id is rejected. Returns the new session descriptor. Give it a prompt to start it working immediately.',
|
|
61
|
+
inputSchema: SPAWN_INPUT,
|
|
62
62
|
},
|
|
63
63
|
{
|
|
64
64
|
name: 'atc_session_input',
|
|
@@ -243,22 +243,11 @@ async function runTool(
|
|
|
243
243
|
case 'atc_session_spawn': {
|
|
244
244
|
const rawAgent = args['agent'];
|
|
245
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
|
-
|
|
255
246
|
const ok = await client.sendRequest('session.spawn', {
|
|
256
247
|
cwd: args['cwd'],
|
|
257
248
|
...(typeof args['name'] === 'string' ? { name: args['name'] } : {}),
|
|
258
249
|
...(typeof args['prompt'] === 'string' ? { prompt: args['prompt'] } : {}),
|
|
259
|
-
...(rawAgent ===
|
|
260
|
-
? { agent: rawAgent }
|
|
261
|
-
: {}),
|
|
250
|
+
...(rawAgent === undefined ? {} : { agent: rawAgent }),
|
|
262
251
|
cols: 100,
|
|
263
252
|
rows: 30,
|
|
264
253
|
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { z } from 'zod';
|
|
2
|
+
import { isRecord } from '../shared/report';
|
|
3
|
+
import { REQUEST_PARAM_SCHEMAS } from './request-param-schemas';
|
|
4
|
+
|
|
5
|
+
export type RequestMethod = keyof typeof REQUEST_PARAM_SCHEMAS;
|
|
6
|
+
|
|
7
|
+
type RequestParams<M extends RequestMethod> = z.infer<(typeof REQUEST_PARAM_SCHEMAS)[M]>;
|
|
8
|
+
|
|
9
|
+
export type ParsedRequestParams<M extends RequestMethod> =
|
|
10
|
+
| { readonly ok: true; readonly data: RequestParams<M> }
|
|
11
|
+
| { readonly ok: false; readonly message: string };
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Validates and defaults one request's params against its method's schema.
|
|
15
|
+
* A malformed shape folds into a single message the caller reports as
|
|
16
|
+
* bad_args, instead of a thrown ZodError.
|
|
17
|
+
*/
|
|
18
|
+
export function parseRequestParams<M extends RequestMethod>(
|
|
19
|
+
method: M,
|
|
20
|
+
rawParams: unknown,
|
|
21
|
+
): ParsedRequestParams<M> {
|
|
22
|
+
const input = isRecord(rawParams) ? rawParams : {};
|
|
23
|
+
const result = REQUEST_PARAM_SCHEMAS[method].safeParse(input);
|
|
24
|
+
|
|
25
|
+
if (!result.success) {
|
|
26
|
+
return { ok: false, message: result.error.issues[0]?.message ?? 'invalid params' };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// oxlint-disable-next-line no-unsafe-type-assertion -- method narrows M, but TS can't follow that through an indexed lookup into the schema map; a successful safeParse against that same lookup guarantees the shape at runtime
|
|
30
|
+
return { ok: true, data: result.data as RequestParams<M> };
|
|
31
|
+
}
|