@zgeoff/atc 0.1.11 → 1.0.0
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 +54 -15
- 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/spawn-picker.ts +305 -0
- package/src/client/to-mirror-session.ts +65 -0
- package/src/{ui.ts → client/ui.ts} +13 -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} +142 -301
- 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} +98 -144
- 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 -227
- /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,84 +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
|
-
}
|
|
77
|
-
|
|
78
|
-
const fleetFile = join(stateDir, 'fleet.json');
|
|
79
|
-
|
|
80
|
-
export interface FleetStore {
|
|
81
|
-
readonly loadFleet: () => FleetEntry[];
|
|
82
|
-
readonly writeFleet: (entries: readonly FleetEntry[]) => void;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
const jsonFleetStore: FleetStore = {
|
|
86
|
-
loadFleet: () => loadFleet(),
|
|
87
|
-
writeFleet: (entries) => {
|
|
88
|
-
try {
|
|
89
|
-
writeFileSync(fleetFile, JSON.stringify(entries, null, 2));
|
|
90
|
-
} catch {}
|
|
91
|
-
},
|
|
92
|
-
};
|
|
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
|
-
|
|
117
|
-
function loadFleet(): FleetEntry[] {
|
|
118
|
-
try {
|
|
119
|
-
const parsed: unknown = JSON.parse(readFileSync(fleetFile, 'utf8'));
|
|
120
|
-
|
|
121
|
-
if (!Array.isArray(parsed)) {
|
|
122
|
-
return [];
|
|
123
|
-
}
|
|
124
|
-
|
|
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;
|
|
136
|
-
} catch {
|
|
137
|
-
return [];
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
|
|
141
72
|
export class SessionManager {
|
|
142
73
|
sessions: Session[] = [];
|
|
143
74
|
|
|
144
|
-
focusedId:
|
|
75
|
+
focusedId: SessionID | null = null;
|
|
145
76
|
|
|
146
77
|
onOutput: (s: Session, data: string) => void = () => {};
|
|
147
78
|
|
|
@@ -149,9 +80,12 @@ export class SessionManager {
|
|
|
149
80
|
|
|
150
81
|
onEvent: (kind: SessionEventKind, s: Session) => void = () => {};
|
|
151
82
|
|
|
152
|
-
|
|
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;
|
|
153
87
|
|
|
154
|
-
private readonly adapters:
|
|
88
|
+
private readonly adapters: Readonly<Record<AgentID, AgentAdapter>>;
|
|
155
89
|
|
|
156
90
|
private readonly store: FleetStore;
|
|
157
91
|
|
|
@@ -159,31 +93,30 @@ export class SessionManager {
|
|
|
159
93
|
|
|
160
94
|
constructor(
|
|
161
95
|
fallback: AgentAdapter,
|
|
162
|
-
store: FleetStore
|
|
96
|
+
store: FleetStore,
|
|
163
97
|
statusPath: string | undefined = statusFile,
|
|
164
|
-
adapters:
|
|
98
|
+
adapters: readonly AgentAdapter[] = [],
|
|
165
99
|
) {
|
|
166
|
-
|
|
167
|
-
|
|
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);
|
|
168
104
|
this.store = store;
|
|
169
105
|
this.statusPath = statusPath ?? statusFile;
|
|
170
106
|
}
|
|
171
107
|
|
|
172
108
|
/**
|
|
173
|
-
* Adapter
|
|
174
|
-
*
|
|
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.
|
|
175
112
|
*/
|
|
176
|
-
findAdapter(
|
|
177
|
-
|
|
178
|
-
return this.adapters.claude ?? this.fallback;
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
return this.adapters[kind] ?? null;
|
|
113
|
+
findAdapter(id: AgentID): AgentAdapter | null {
|
|
114
|
+
return this.adapters[id] ?? null;
|
|
182
115
|
}
|
|
183
116
|
|
|
184
117
|
// Hands a live terminal session off to a headless run: the terminal dies,
|
|
185
118
|
// the record lives on as a headless session and keeps its screen history.
|
|
186
|
-
yankHeadless(id:
|
|
119
|
+
yankHeadless(id: SessionID): Session | null {
|
|
187
120
|
const s = this.sessions.find((x) => x.id === id);
|
|
188
121
|
|
|
189
122
|
if (!s || s.pty === null || s.agentSessionID === undefined) {
|
|
@@ -206,17 +139,31 @@ export class SessionManager {
|
|
|
206
139
|
|
|
207
140
|
// Registers a fleet entry as a session with no terminal yet, so a
|
|
208
141
|
// fleet-wide restore can show every incoming session at once; adopting it
|
|
209
|
-
// later attaches the terminal.
|
|
142
|
+
// later attaches the terminal. Exited entries come back as killed
|
|
143
|
+
// sessions: still listed and revivable, never auto-adopted.
|
|
210
144
|
restore(entry: FleetEntry): Session {
|
|
145
|
+
const exited = entry.exited === true;
|
|
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
|
+
|
|
211
158
|
const session: Session = {
|
|
212
|
-
id:
|
|
159
|
+
id: mintSessionID(),
|
|
213
160
|
name: entry.name,
|
|
214
161
|
cwd: entry.cwd,
|
|
215
162
|
kind: 'headless',
|
|
216
163
|
pty: null,
|
|
217
|
-
state: 'running',
|
|
164
|
+
state: exited ? 'exited' : 'running',
|
|
218
165
|
unread: false,
|
|
219
|
-
lastMsg
|
|
166
|
+
lastMsg,
|
|
220
167
|
agentSessionID: entry.agentSessionID,
|
|
221
168
|
agent: entry.agent,
|
|
222
169
|
pinned: entry.pinned ?? false,
|
|
@@ -235,7 +182,7 @@ export class SessionManager {
|
|
|
235
182
|
|
|
236
183
|
// Adopts a headless session back into a terminal: a fresh PTY resumes the
|
|
237
184
|
// same agent session id.
|
|
238
|
-
adoptTerminal(id:
|
|
185
|
+
adoptTerminal(id: SessionID, cols: number, rows: number): Session | null {
|
|
239
186
|
const s = this.sessions.find((x) => x.id === id);
|
|
240
187
|
|
|
241
188
|
if (!s || s.pty !== null || s.agentSessionID === undefined) {
|
|
@@ -280,7 +227,7 @@ export class SessionManager {
|
|
|
280
227
|
this.emitChange();
|
|
281
228
|
});
|
|
282
229
|
|
|
283
|
-
this.writeFleet();
|
|
230
|
+
void this.writeFleet();
|
|
284
231
|
this.onEvent('state', s);
|
|
285
232
|
this.emitChange();
|
|
286
233
|
|
|
@@ -292,7 +239,7 @@ export class SessionManager {
|
|
|
292
239
|
* user strength, so auto-summaries stop overwriting it while an
|
|
293
240
|
* in-session rename still wins. Pinned sessions lead every list.
|
|
294
241
|
*/
|
|
295
|
-
updateSession(id:
|
|
242
|
+
updateSession(id: SessionID, name?: string, pinned?: boolean): boolean {
|
|
296
243
|
const s = this.sessions.find((x) => x.id === id);
|
|
297
244
|
|
|
298
245
|
if (s === undefined) {
|
|
@@ -312,14 +259,14 @@ export class SessionManager {
|
|
|
312
259
|
this.onEvent('state', s);
|
|
313
260
|
}
|
|
314
261
|
|
|
315
|
-
this.writeFleet();
|
|
262
|
+
void this.writeFleet();
|
|
316
263
|
this.writeStatus();
|
|
317
264
|
this.emitChange();
|
|
318
265
|
|
|
319
266
|
return true;
|
|
320
267
|
}
|
|
321
268
|
|
|
322
|
-
updateSurfaceState(id:
|
|
269
|
+
updateSurfaceState(id: SessionID, state: SessionState, msg: string) {
|
|
323
270
|
const s = this.sessions.find((x) => x.id === id);
|
|
324
271
|
|
|
325
272
|
if (!s || s.kind !== 'headless') {
|
|
@@ -336,7 +283,7 @@ export class SessionManager {
|
|
|
336
283
|
|
|
337
284
|
// The detector-stack screen tier reports through here: only flips between
|
|
338
285
|
// running and needs_you — hook-driven done/exited states always win.
|
|
339
|
-
updateAttention(id:
|
|
286
|
+
updateAttention(id: SessionID, state: 'needs_you' | 'running', msg: string) {
|
|
340
287
|
const s = this.sessions.find((x) => x.id === id);
|
|
341
288
|
|
|
342
289
|
if (!s || s.pty === null || s.state === state) {
|
|
@@ -362,17 +309,17 @@ export class SessionManager {
|
|
|
362
309
|
return this.sessions.find((s) => s.id === this.focusedId) ?? null;
|
|
363
310
|
}
|
|
364
311
|
|
|
365
|
-
// resume: true opens the agent's own session picker;
|
|
366
|
-
// specific
|
|
312
|
+
// resume: true opens the agent's own session picker; an agent session id
|
|
313
|
+
// resumes that specific session (fleet restore).
|
|
367
314
|
spawn(
|
|
368
315
|
cwd: string,
|
|
369
316
|
name: string,
|
|
370
317
|
prompt: string,
|
|
371
318
|
cols: number,
|
|
372
319
|
rows: number,
|
|
373
|
-
resume: boolean |
|
|
320
|
+
resume: boolean | AgentSessionID = false,
|
|
374
321
|
namedBy: 'user' | 'auto' = 'auto',
|
|
375
|
-
agent:
|
|
322
|
+
agent: AgentID = 'claude',
|
|
376
323
|
): Session {
|
|
377
324
|
const adapter = this.findAdapter(agent);
|
|
378
325
|
|
|
@@ -380,7 +327,7 @@ export class SessionManager {
|
|
|
380
327
|
throw new Error(`no adapter for agent '${agent}'`);
|
|
381
328
|
}
|
|
382
329
|
|
|
383
|
-
const id =
|
|
330
|
+
const id = mintSessionID();
|
|
384
331
|
const plan = adapter.planSpawn({ prompt, resume });
|
|
385
332
|
|
|
386
333
|
const pty = spawn(plan.bin, plan.args, {
|
|
@@ -435,7 +382,7 @@ export class SessionManager {
|
|
|
435
382
|
});
|
|
436
383
|
|
|
437
384
|
this.sessions.push(session);
|
|
438
|
-
this.writeFleet();
|
|
385
|
+
void this.writeFleet();
|
|
439
386
|
this.writeStatus();
|
|
440
387
|
this.onEvent('added', session);
|
|
441
388
|
|
|
@@ -460,6 +407,7 @@ export class SessionManager {
|
|
|
460
407
|
createdAt: s.createdAt,
|
|
461
408
|
kind: s.kind,
|
|
462
409
|
alive: s.pty !== null || (s.kind === 'headless' && s.state !== 'exited'),
|
|
410
|
+
canEject: (this.findAdapter(s.agent)?.headlessRunner ?? null) !== null,
|
|
463
411
|
}));
|
|
464
412
|
}
|
|
465
413
|
|
|
@@ -491,9 +439,7 @@ export class SessionManager {
|
|
|
491
439
|
|
|
492
440
|
if (ev.agentSessionID !== undefined && s.agentSessionID !== ev.agentSessionID) {
|
|
493
441
|
s.agentSessionID = ev.agentSessionID;
|
|
494
|
-
|
|
495
|
-
this.writeFleet();
|
|
496
|
-
|
|
442
|
+
void this.writeFleet();
|
|
497
443
|
dirty = true;
|
|
498
444
|
}
|
|
499
445
|
|
|
@@ -587,13 +533,13 @@ export class SessionManager {
|
|
|
587
533
|
s.namedBy = update.namedBy;
|
|
588
534
|
}
|
|
589
535
|
|
|
590
|
-
this.writeFleet();
|
|
536
|
+
void this.writeFleet();
|
|
591
537
|
this.onEvent('renamed', s);
|
|
592
538
|
this.emitChange();
|
|
593
539
|
}
|
|
594
540
|
|
|
595
541
|
// Shell command that re-opens this session outside atc (or anywhere).
|
|
596
|
-
buildResumeCommand(id:
|
|
542
|
+
buildResumeCommand(id: SessionID): string | null {
|
|
597
543
|
const s = this.sessions.find((x) => x.id === id);
|
|
598
544
|
|
|
599
545
|
if (!s) {
|
|
@@ -603,7 +549,7 @@ export class SessionManager {
|
|
|
603
549
|
return this.findAdapter(s.agent)?.buildResumeCommand(s.cwd, s.agentSessionID) ?? null;
|
|
604
550
|
}
|
|
605
551
|
|
|
606
|
-
attach(id:
|
|
552
|
+
attach(id: SessionID) {
|
|
607
553
|
const s = this.sessions.find((x) => x.id === id);
|
|
608
554
|
|
|
609
555
|
if (!s) {
|
|
@@ -612,8 +558,7 @@ export class SessionManager {
|
|
|
612
558
|
|
|
613
559
|
s.unread = false;
|
|
614
560
|
s.lastAttachedAt = Date.now();
|
|
615
|
-
|
|
616
|
-
this.writeFleet();
|
|
561
|
+
void this.writeFleet();
|
|
617
562
|
|
|
618
563
|
// Attaching answers the attention request: a still-pending prompt
|
|
619
564
|
// re-flags it via the next notification.
|
|
@@ -626,7 +571,7 @@ export class SessionManager {
|
|
|
626
571
|
this.emitChange();
|
|
627
572
|
}
|
|
628
573
|
|
|
629
|
-
ack(id:
|
|
574
|
+
ack(id: SessionID) {
|
|
630
575
|
const s = this.sessions.find((x) => x.id === id);
|
|
631
576
|
|
|
632
577
|
if (s) {
|
|
@@ -638,7 +583,10 @@ export class SessionManager {
|
|
|
638
583
|
this.emitChange();
|
|
639
584
|
}
|
|
640
585
|
|
|
641
|
-
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> {
|
|
642
590
|
const s = this.sessions.find((x) => x.id === id);
|
|
643
591
|
|
|
644
592
|
if (!s) {
|
|
@@ -663,7 +611,8 @@ export class SessionManager {
|
|
|
663
611
|
this.onEvent('removed', s);
|
|
664
612
|
}
|
|
665
613
|
|
|
666
|
-
this.writeFleet();
|
|
614
|
+
await this.writeFleet();
|
|
615
|
+
|
|
667
616
|
this.emitChange();
|
|
668
617
|
}
|
|
669
618
|
|
|
@@ -691,26 +640,31 @@ export class SessionManager {
|
|
|
691
640
|
|
|
692
641
|
// Persisted continuously so a crash (or quit) leaves a restorable fleet.
|
|
693
642
|
// Deliberate kills rewrite the file; unexpected session/atc deaths do not,
|
|
694
|
-
// so the last known fleet survives for `R` restore.
|
|
695
|
-
|
|
643
|
+
// so the last known fleet survives for `R` restore. Sessions whose
|
|
644
|
+
// terminal is gone persist as exited entries, so the killed archive
|
|
645
|
+
// survives a daemon restart too.
|
|
646
|
+
async writeFleet(): Promise<void> {
|
|
696
647
|
const fleet: FleetEntry[] = [];
|
|
697
648
|
|
|
698
649
|
for (const s of this.sessions) {
|
|
650
|
+
if (s.agentSessionID === undefined) {
|
|
651
|
+
continue;
|
|
652
|
+
}
|
|
653
|
+
|
|
699
654
|
const live = s.pty !== null || (s.kind === 'headless' && s.state !== 'exited');
|
|
700
655
|
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
}
|
|
656
|
+
fleet.push({
|
|
657
|
+
name: s.name,
|
|
658
|
+
cwd: s.cwd,
|
|
659
|
+
agentSessionID: s.agentSessionID,
|
|
660
|
+
agent: s.agent,
|
|
661
|
+
...(s.pinned ? { pinned: true } : {}),
|
|
662
|
+
lastAttachedAt: s.lastAttachedAt,
|
|
663
|
+
...(live ? {} : { exited: true }),
|
|
664
|
+
});
|
|
711
665
|
}
|
|
712
666
|
|
|
713
|
-
this.store.writeFleet(fleet);
|
|
667
|
+
await this.store.writeFleet(fleet);
|
|
714
668
|
}
|
|
715
669
|
|
|
716
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
|
});
|