@zgeoff/atc 0.1.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/LICENSE +21 -0
- package/README.md +85 -0
- package/bin/atc +2 -0
- package/package.json +58 -0
- package/src/agent-adapter.ts +60 -0
- package/src/attach-registry.ts +89 -0
- package/src/boot-daemon.ts +106 -0
- package/src/claude-adapter.ts +235 -0
- package/src/cli.ts +101 -0
- package/src/collect-clean-env.ts +21 -0
- package/src/config.ts +57 -0
- package/src/daemon-client.ts +131 -0
- package/src/daemon-connection.ts +511 -0
- package/src/daemon-error.ts +16 -0
- package/src/daemon.ts +648 -0
- package/src/dirs.ts +67 -0
- package/src/get-build.ts +32 -0
- package/src/hook-report.ts +31 -0
- package/src/hooks.ts +54 -0
- package/src/index.ts +1076 -0
- package/src/mcp-server.ts +272 -0
- package/src/outbound-queue.ts +85 -0
- package/src/permission-registry.ts +104 -0
- package/src/protocol.ts +129 -0
- package/src/report.ts +33 -0
- package/src/screen-model.ts +48 -0
- package/src/sessions.ts +568 -0
- package/src/start-headless-run.ts +160 -0
- package/src/state-store.ts +131 -0
- package/src/statusline.ts +103 -0
- package/src/ui.ts +341 -0
package/src/daemon.ts
ADDED
|
@@ -0,0 +1,648 @@
|
|
|
1
|
+
import { existsSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import type { AgentAdapter } from './agent-adapter';
|
|
3
|
+
import { AttachRegistry } from './attach-registry';
|
|
4
|
+
import type { Dims } from './attach-registry';
|
|
5
|
+
import { DaemonConnection } from './daemon-connection';
|
|
6
|
+
import type { DaemonContext, OutputClient } from './daemon-connection';
|
|
7
|
+
import { startHookServer } from './hooks';
|
|
8
|
+
import { PermissionRegistry } from './permission-registry';
|
|
9
|
+
import { MAX_CHUNK, PROTOCOL_V } from './protocol';
|
|
10
|
+
import type { EventMsg } from './protocol';
|
|
11
|
+
import { ScreenModel } from './screen-model';
|
|
12
|
+
import { SessionManager } from './sessions';
|
|
13
|
+
import type { SessionDescriptor, SessionState } from './sessions';
|
|
14
|
+
import { StateStore } from './state-store';
|
|
15
|
+
|
|
16
|
+
export interface DaemonOptions {
|
|
17
|
+
readonly socketPath: string;
|
|
18
|
+
readonly reporterSocketPath: string;
|
|
19
|
+
|
|
20
|
+
// Build string sent in the handshake and in mismatch errors, e.g. "atc/0.1.0".
|
|
21
|
+
readonly build: string;
|
|
22
|
+
readonly adapter: AgentAdapter;
|
|
23
|
+
|
|
24
|
+
// SQLite path for daemon state; a fleet.json at legacyFleetPath seeds the
|
|
25
|
+
// fleet table once so upgrading keeps the restorable fleet.
|
|
26
|
+
readonly dbPath: string;
|
|
27
|
+
readonly legacyFleetPath?: string;
|
|
28
|
+
|
|
29
|
+
// When set, the daemon's pid is written here and removed on stop.
|
|
30
|
+
readonly pidPath?: string;
|
|
31
|
+
|
|
32
|
+
// Where the statusline contract file is written; defaults to the real one.
|
|
33
|
+
readonly statusPath?: string;
|
|
34
|
+
|
|
35
|
+
// Outbound queue capacity per client; small values force desync in tests.
|
|
36
|
+
readonly queueBytes?: number;
|
|
37
|
+
|
|
38
|
+
// Runs one headless turn over a session; the real one drives the Agent
|
|
39
|
+
// SDK, which spawns real claude — a boundary tests never cross. Absent
|
|
40
|
+
// means eject is unsupported.
|
|
41
|
+
readonly headlessRunner?: HeadlessRunner;
|
|
42
|
+
|
|
43
|
+
// How long an eject waits for the dying terminal to report SessionEnd
|
|
44
|
+
// before starting the headless run anyway.
|
|
45
|
+
readonly ejectSettleMs?: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface HeadlessRunRequest {
|
|
49
|
+
readonly cwd: string;
|
|
50
|
+
readonly prompt: string;
|
|
51
|
+
readonly resume?: string;
|
|
52
|
+
readonly permissionMode?: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface HeadlessRunEvents {
|
|
56
|
+
readonly onOutput: (text: string) => void;
|
|
57
|
+
readonly onDone: (summary: string) => void;
|
|
58
|
+
readonly onNeedsYou: (msg: string) => void;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export type HeadlessRunner = (
|
|
62
|
+
opts: HeadlessRunRequest,
|
|
63
|
+
hooks: HeadlessRunEvents,
|
|
64
|
+
) => { readonly stop: () => void };
|
|
65
|
+
|
|
66
|
+
export interface DaemonHandle {
|
|
67
|
+
readonly stop: () => void;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The daemon: owns the sessions, the client-protocol listener, and the
|
|
72
|
+
* reporter listener. Protocol requests are NDJSON lines, one response per
|
|
73
|
+
* request, and state changes broadcast to every connected client. The first
|
|
74
|
+
* request on a connection must be `daemon.hello`; an unknown method is an
|
|
75
|
+
* error, never a disconnect; a malformed or oversized line is a disconnect,
|
|
76
|
+
* because transports guarantee byte integrity and such a line means a buggy
|
|
77
|
+
* or hostile peer.
|
|
78
|
+
*/
|
|
79
|
+
export function startDaemon(opts: DaemonOptions): DaemonHandle {
|
|
80
|
+
if (opts.pidPath !== undefined) {
|
|
81
|
+
writeFileSync(opts.pidPath, String(process.pid));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const store = new StateStore(opts.dbPath, opts.legacyFleetPath);
|
|
85
|
+
|
|
86
|
+
const mgr =
|
|
87
|
+
opts.statusPath === undefined
|
|
88
|
+
? new SessionManager(opts.adapter, store)
|
|
89
|
+
: new SessionManager(opts.adapter, store, opts.statusPath);
|
|
90
|
+
|
|
91
|
+
const clients = new Set<DaemonConnection>();
|
|
92
|
+
|
|
93
|
+
const emitEvent = (event: EventMsg) => {
|
|
94
|
+
for (const client of clients) {
|
|
95
|
+
client.sendEvent(event);
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const registry = new PermissionRegistry();
|
|
100
|
+
|
|
101
|
+
registry.onRequested = (req) => {
|
|
102
|
+
emitEvent({
|
|
103
|
+
v: PROTOCOL_V,
|
|
104
|
+
ev: 'permission.requested',
|
|
105
|
+
request: req.id,
|
|
106
|
+
s: req.sessionID,
|
|
107
|
+
message: req.message,
|
|
108
|
+
respondable: req.respondable,
|
|
109
|
+
});
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
registry.onResolved = (id, decision) => {
|
|
113
|
+
emitEvent({ v: PROTOCOL_V, ev: 'permission.resolved', request: id, decision });
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const headlessRuns = new Map<string, { readonly stop: () => void }>();
|
|
117
|
+
|
|
118
|
+
// Resuming an agent session while its old process is still shutting down
|
|
119
|
+
// corrupts the handoff, so an eject waits for the terminal's final report
|
|
120
|
+
// (or a settle timeout) before the headless run starts.
|
|
121
|
+
const pendingEjects = new Map<string, () => void>();
|
|
122
|
+
|
|
123
|
+
const startHeadlessTurn = (sessionID: string, prompt: string): boolean => {
|
|
124
|
+
const runner = opts.headlessRunner;
|
|
125
|
+
const s = mgr.sessions.find((x) => x.id === sessionID);
|
|
126
|
+
|
|
127
|
+
if (runner === undefined || s === undefined || headlessRuns.has(sessionID)) {
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
mgr.updateSurfaceState(sessionID, 'running', 'headless turn running');
|
|
132
|
+
|
|
133
|
+
const handle = runner(
|
|
134
|
+
{
|
|
135
|
+
cwd: s.cwd,
|
|
136
|
+
prompt,
|
|
137
|
+
...(s.claudeId === undefined ? {} : { resume: s.claudeId }),
|
|
138
|
+
permissionMode: 'auto',
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
onOutput: (text) => {
|
|
142
|
+
mgr.onOutput(s, text);
|
|
143
|
+
},
|
|
144
|
+
onDone: (summary) => {
|
|
145
|
+
headlessRuns.delete(sessionID);
|
|
146
|
+
|
|
147
|
+
const doneMsg = summary === '' ? 'headless turn done' : summary;
|
|
148
|
+
|
|
149
|
+
mgr.updateSurfaceState(sessionID, 'done', doneMsg);
|
|
150
|
+
},
|
|
151
|
+
onNeedsYou: (msg) => {
|
|
152
|
+
headlessRuns.delete(sessionID);
|
|
153
|
+
mgr.updateSurfaceState(sessionID, 'needs_you', msg);
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
headlessRuns.set(sessionID, handle);
|
|
159
|
+
|
|
160
|
+
return true;
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
const attachments = new AttachRegistry<OutputClient>();
|
|
164
|
+
const seqs = new Map<string, number>();
|
|
165
|
+
const ptyDims = new Map<string, Dims>();
|
|
166
|
+
const screens = new Map<string, ScreenModel>();
|
|
167
|
+
const resizeTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
168
|
+
const detectTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
169
|
+
|
|
170
|
+
// The screen tier of the detector stack: once a session's output has
|
|
171
|
+
// quiesced, judge the serialized screen and flip running/needs_you.
|
|
172
|
+
const scheduleDetect = (sessionID: string) => {
|
|
173
|
+
const detector = opts.adapter.screenDetector;
|
|
174
|
+
|
|
175
|
+
if (detector === null) {
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const pending = detectTimers.get(sessionID);
|
|
180
|
+
|
|
181
|
+
if (pending !== undefined) {
|
|
182
|
+
clearTimeout(pending);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
detectTimers.set(
|
|
186
|
+
sessionID,
|
|
187
|
+
setTimeout(() => {
|
|
188
|
+
detectTimers.delete(sessionID);
|
|
189
|
+
void applyScreenJudgment(sessionID);
|
|
190
|
+
}, 300),
|
|
191
|
+
);
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
// Replay is the serialized screen sent as ordinary output events: the
|
|
195
|
+
// client cannot tell replay from live and does not need to. A clear leads
|
|
196
|
+
// so a stale or desynced client screen resets first.
|
|
197
|
+
const sendReplay = async (sessionID: string, client: OutputClient) => {
|
|
198
|
+
const model = screens.get(sessionID);
|
|
199
|
+
|
|
200
|
+
if (model === undefined) {
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const replay = `\u001B[2J\u001B[H${await model.renderReplay()}`;
|
|
205
|
+
const seq = seqs.get(sessionID) ?? 0;
|
|
206
|
+
|
|
207
|
+
for (let i = 0; i < replay.length; i += MAX_CHUNK) {
|
|
208
|
+
const chunk = replay.slice(i, i + MAX_CHUNK);
|
|
209
|
+
|
|
210
|
+
client.sendOutput(
|
|
211
|
+
sessionID,
|
|
212
|
+
{ v: PROTOCOL_V, ev: 'session.output', s: sessionID, seq, d: chunk },
|
|
213
|
+
chunk.length,
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
const applyEffectiveDims = (sessionID: string) => {
|
|
219
|
+
const dims = attachments.findEffectiveDims(sessionID);
|
|
220
|
+
|
|
221
|
+
if (dims === null) {
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const prev = ptyDims.get(sessionID);
|
|
226
|
+
|
|
227
|
+
if (prev !== undefined && prev.cols === dims.cols && prev.rows === dims.rows) {
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const s = mgr.sessions.find((x) => x.id === sessionID);
|
|
232
|
+
|
|
233
|
+
s?.pty?.resize(dims.cols, dims.rows);
|
|
234
|
+
screens.get(sessionID)?.updateDims(dims.cols, dims.rows);
|
|
235
|
+
ptyDims.set(sessionID, dims);
|
|
236
|
+
|
|
237
|
+
emitEvent({
|
|
238
|
+
v: PROTOCOL_V,
|
|
239
|
+
ev: 'session.resized',
|
|
240
|
+
s: sessionID,
|
|
241
|
+
cols: dims.cols,
|
|
242
|
+
rows: dims.rows,
|
|
243
|
+
});
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
// Debounced so two clients resizing in opposite directions cannot produce
|
|
247
|
+
// a SIGWINCH storm; a no-op effective size never reaches the PTY.
|
|
248
|
+
const scheduleResize = (sessionID: string) => {
|
|
249
|
+
if (resizeTimers.has(sessionID)) {
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
resizeTimers.set(
|
|
254
|
+
sessionID,
|
|
255
|
+
setTimeout(() => {
|
|
256
|
+
resizeTimers.delete(sessionID);
|
|
257
|
+
|
|
258
|
+
applyEffectiveDims(sessionID);
|
|
259
|
+
}, 50),
|
|
260
|
+
);
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
const applyScreenJudgment = async (sessionID: string) => {
|
|
264
|
+
const detector = opts.adapter.screenDetector;
|
|
265
|
+
const model = screens.get(sessionID);
|
|
266
|
+
|
|
267
|
+
if (detector === null || model === undefined) {
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const screen = await model.renderReplay();
|
|
272
|
+
|
|
273
|
+
const judgment = detector.detectAttention(screen);
|
|
274
|
+
|
|
275
|
+
if (judgment === 'needs-input') {
|
|
276
|
+
mgr.updateAttention(sessionID, 'needs_you', 'waiting at a prompt');
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (judgment === 'working') {
|
|
280
|
+
mgr.updateAttention(sessionID, 'running', 'working');
|
|
281
|
+
}
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
mgr.onOutput = (s, data) => {
|
|
285
|
+
// The screen model consumes every byte continuously — background output
|
|
286
|
+
// is consumed, not discarded.
|
|
287
|
+
screens.get(s.id)?.record(data);
|
|
288
|
+
scheduleDetect(s.id);
|
|
289
|
+
|
|
290
|
+
const conns = attachments.collectClients(s.id);
|
|
291
|
+
|
|
292
|
+
if (conns.length === 0) {
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
let seq = seqs.get(s.id) ?? 0;
|
|
297
|
+
|
|
298
|
+
for (let i = 0; i < data.length; i += MAX_CHUNK) {
|
|
299
|
+
const chunk = data.slice(i, i + MAX_CHUNK);
|
|
300
|
+
|
|
301
|
+
seq++;
|
|
302
|
+
|
|
303
|
+
const event: EventMsg = { v: PROTOCOL_V, ev: 'session.output', s: s.id, seq, d: chunk };
|
|
304
|
+
|
|
305
|
+
for (const conn of conns) {
|
|
306
|
+
conn.sendOutput(s.id, event, chunk.length);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
seqs.set(s.id, seq);
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
// Permission requests are synthesized from attention transitions: entering
|
|
314
|
+
// needs_you opens one, and leaving it (answered directly in the terminal,
|
|
315
|
+
// or the session dying) dismisses whatever is pending.
|
|
316
|
+
const lastStates = new Map<string, SessionState>();
|
|
317
|
+
|
|
318
|
+
const recordAttention: SessionManager['onEvent'] = (kind, s) => {
|
|
319
|
+
if (kind === 'removed') {
|
|
320
|
+
registry.answerAll(s.id, 'dismissed');
|
|
321
|
+
lastStates.delete(s.id);
|
|
322
|
+
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const prev = lastStates.get(s.id);
|
|
327
|
+
|
|
328
|
+
lastStates.set(s.id, s.state);
|
|
329
|
+
|
|
330
|
+
if (s.state === 'needs_you' && prev !== 'needs_you') {
|
|
331
|
+
registry.open(s.id, s.lastMsg, false);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
if (prev === 'needs_you' && s.state !== 'needs_you') {
|
|
335
|
+
registry.answerAll(s.id, 'dismissed');
|
|
336
|
+
}
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
mgr.onEvent = (kind, s) => {
|
|
340
|
+
recordAttention(kind, s);
|
|
341
|
+
|
|
342
|
+
if (kind === 'removed') {
|
|
343
|
+
attachments.removeSession(s.id);
|
|
344
|
+
seqs.delete(s.id);
|
|
345
|
+
ptyDims.delete(s.id);
|
|
346
|
+
screens.get(s.id)?.stop();
|
|
347
|
+
screens.delete(s.id);
|
|
348
|
+
|
|
349
|
+
const pendingDetect = detectTimers.get(s.id);
|
|
350
|
+
|
|
351
|
+
if (pendingDetect !== undefined) {
|
|
352
|
+
clearTimeout(pendingDetect);
|
|
353
|
+
|
|
354
|
+
detectTimers.delete(s.id);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const builders: Record<typeof kind, () => EventMsg> = {
|
|
359
|
+
added: () => ({ v: PROTOCOL_V, ev: 'session.added', session: getDescriptor(mgr, s.id) }),
|
|
360
|
+
state: () => ({
|
|
361
|
+
v: PROTOCOL_V,
|
|
362
|
+
ev: 'session.state',
|
|
363
|
+
s: s.id,
|
|
364
|
+
state: s.state,
|
|
365
|
+
unread: s.unread,
|
|
366
|
+
lastMsg: s.lastMsg,
|
|
367
|
+
kind: s.kind,
|
|
368
|
+
alive: s.pty !== null || (s.kind === 'jsonl' && s.state !== 'exited'),
|
|
369
|
+
...(s.claudeId === undefined ? {} : { claudeId: s.claudeId }),
|
|
370
|
+
}),
|
|
371
|
+
renamed: () => ({
|
|
372
|
+
v: PROTOCOL_V,
|
|
373
|
+
ev: 'session.renamed',
|
|
374
|
+
s: s.id,
|
|
375
|
+
name: s.name,
|
|
376
|
+
namedBy: s.namedBy,
|
|
377
|
+
}),
|
|
378
|
+
removed: () => ({ v: PROTOCOL_V, ev: 'session.removed', s: s.id }),
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
emitEvent(builders[kind]());
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
const reporter = startHookServer((e) => {
|
|
385
|
+
if (e.event !== 'Statusline') {
|
|
386
|
+
store.recordEvent(e);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
if (e.event === 'SessionEnd') {
|
|
390
|
+
pendingEjects.get(e.atcId)?.();
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
mgr.applyHook(e);
|
|
394
|
+
}, opts.reporterSocketPath);
|
|
395
|
+
|
|
396
|
+
const ctx: DaemonContext = {
|
|
397
|
+
build: opts.build,
|
|
398
|
+
collectSessions: () => mgr.collectDescriptors(),
|
|
399
|
+
collectSpawnDirs: () => store.collectSpawnDirs(),
|
|
400
|
+
collectFleet: () => store.loadFleet(),
|
|
401
|
+
spawnSession: (p) => {
|
|
402
|
+
const s = mgr.spawn(p.cwd, p.name, p.prompt, p.cols, p.rows, p.resume, p.namedBy);
|
|
403
|
+
|
|
404
|
+
ptyDims.set(s.id, { cols: p.cols, rows: p.rows });
|
|
405
|
+
screens.set(s.id, new ScreenModel(p.cols, p.rows));
|
|
406
|
+
store.recordSpawnDir(p.cwd);
|
|
407
|
+
|
|
408
|
+
return getDescriptor(mgr, s.id);
|
|
409
|
+
},
|
|
410
|
+
killSession: (id) => {
|
|
411
|
+
if (!mgr.sessions.some((s) => s.id === id)) {
|
|
412
|
+
return false;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
headlessRuns.get(id)?.stop();
|
|
416
|
+
headlessRuns.delete(id);
|
|
417
|
+
mgr.kill(id);
|
|
418
|
+
|
|
419
|
+
return true;
|
|
420
|
+
},
|
|
421
|
+
ejectSession: (id, prompt) => {
|
|
422
|
+
if (opts.headlessRunner === undefined) {
|
|
423
|
+
return 'unsupported';
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
if (!hasResumableTranscript(mgr, id)) {
|
|
427
|
+
return 'no_transcript';
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const yanked = mgr.yankHeadless(id);
|
|
431
|
+
|
|
432
|
+
if (yanked === null) {
|
|
433
|
+
return 'missing';
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const settled = Promise.withResolvers<void>();
|
|
437
|
+
const timer = setTimeout(settled.resolve, opts.ejectSettleMs ?? 4000);
|
|
438
|
+
|
|
439
|
+
pendingEjects.set(id, settled.resolve);
|
|
440
|
+
|
|
441
|
+
void (async () => {
|
|
442
|
+
await settled.promise;
|
|
443
|
+
|
|
444
|
+
clearTimeout(timer);
|
|
445
|
+
|
|
446
|
+
pendingEjects.delete(id);
|
|
447
|
+
|
|
448
|
+
startHeadlessTurn(id, prompt);
|
|
449
|
+
})();
|
|
450
|
+
|
|
451
|
+
return 'ok';
|
|
452
|
+
},
|
|
453
|
+
adoptSession: (id, cols, rows) => {
|
|
454
|
+
if (!hasResumableTranscript(mgr, id)) {
|
|
455
|
+
return 'no_transcript';
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
headlessRuns.get(id)?.stop();
|
|
459
|
+
headlessRuns.delete(id);
|
|
460
|
+
|
|
461
|
+
const adopted = mgr.adoptTerminal(id, cols, rows);
|
|
462
|
+
|
|
463
|
+
if (adopted === null) {
|
|
464
|
+
return 'missing';
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
ptyDims.set(id, { cols, rows });
|
|
468
|
+
|
|
469
|
+
scheduleResize(id);
|
|
470
|
+
|
|
471
|
+
return 'ok';
|
|
472
|
+
},
|
|
473
|
+
ackSession: (id) => {
|
|
474
|
+
if (!mgr.sessions.some((s) => s.id === id)) {
|
|
475
|
+
return false;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
mgr.ack(id);
|
|
479
|
+
|
|
480
|
+
return true;
|
|
481
|
+
},
|
|
482
|
+
buildResumeCommand: (id) => mgr.buildResumeCommand(id),
|
|
483
|
+
answerPermission: (request, decision) => registry.answer(request, decision),
|
|
484
|
+
attachSession: (client, sessionID, dims) => {
|
|
485
|
+
const s = mgr.sessions.find((x) => x.id === sessionID);
|
|
486
|
+
|
|
487
|
+
if (s === undefined) {
|
|
488
|
+
return 'missing';
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
if (s.pty === null) {
|
|
492
|
+
return 'dead';
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
attachments.attach(sessionID, client, dims);
|
|
496
|
+
mgr.attach(sessionID);
|
|
497
|
+
|
|
498
|
+
scheduleResize(sessionID);
|
|
499
|
+
void sendReplay(sessionID, client);
|
|
500
|
+
|
|
501
|
+
return 'ok';
|
|
502
|
+
},
|
|
503
|
+
detachSession: (client, sessionID) => {
|
|
504
|
+
attachments.detach(sessionID, client);
|
|
505
|
+
|
|
506
|
+
scheduleResize(sessionID);
|
|
507
|
+
},
|
|
508
|
+
detachClient: (client) => {
|
|
509
|
+
for (const sessionID of attachments.detachAll(client)) {
|
|
510
|
+
scheduleResize(sessionID);
|
|
511
|
+
}
|
|
512
|
+
},
|
|
513
|
+
writeSessionInput: (sessionID, data) => {
|
|
514
|
+
const s = mgr.sessions.find((x) => x.id === sessionID);
|
|
515
|
+
|
|
516
|
+
if (s === undefined) {
|
|
517
|
+
return 'missing';
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
if (s.kind === 'jsonl') {
|
|
521
|
+
if (headlessRuns.has(sessionID)) {
|
|
522
|
+
return 'busy';
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
return startHeadlessTurn(sessionID, data.trimEnd()) ? 'ok' : 'dead';
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
if (s.pty === null) {
|
|
529
|
+
return 'dead';
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
s.pty.write(data);
|
|
533
|
+
|
|
534
|
+
return 'ok';
|
|
535
|
+
},
|
|
536
|
+
resizeSession: (client, sessionID, dims) => {
|
|
537
|
+
if (!attachments.updateDims(sessionID, client, dims)) {
|
|
538
|
+
return false;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
scheduleResize(sessionID);
|
|
542
|
+
|
|
543
|
+
return true;
|
|
544
|
+
},
|
|
545
|
+
resyncClient: sendReplay,
|
|
546
|
+
...(opts.queueBytes === undefined ? {} : { queueBytes: opts.queueBytes }),
|
|
547
|
+
getEffectiveDims: (sessionID) =>
|
|
548
|
+
attachments.findEffectiveDims(sessionID) ?? ptyDims.get(sessionID) ?? { cols: 80, rows: 24 },
|
|
549
|
+
restoreFleet: (cols, rows) => {
|
|
550
|
+
let restored = 0;
|
|
551
|
+
|
|
552
|
+
for (const entry of store.loadFleet()) {
|
|
553
|
+
const live = mgr.sessions.some((s) => s.pty !== null && s.claudeId === entry.claudeId);
|
|
554
|
+
|
|
555
|
+
if (live) {
|
|
556
|
+
continue;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
const s = mgr.spawn(entry.cwd, entry.name, '', cols, rows, entry.claudeId);
|
|
560
|
+
|
|
561
|
+
ptyDims.set(s.id, { cols, rows });
|
|
562
|
+
screens.set(s.id, new ScreenModel(cols, rows));
|
|
563
|
+
|
|
564
|
+
restored++;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
return restored;
|
|
568
|
+
},
|
|
569
|
+
};
|
|
570
|
+
|
|
571
|
+
try {
|
|
572
|
+
unlinkSync(opts.socketPath);
|
|
573
|
+
} catch {}
|
|
574
|
+
|
|
575
|
+
const server = Bun.listen<DaemonConnection>({
|
|
576
|
+
unix: opts.socketPath,
|
|
577
|
+
socket: {
|
|
578
|
+
open(socket) {
|
|
579
|
+
socket.data = new DaemonConnection(socket, ctx);
|
|
580
|
+
|
|
581
|
+
clients.add(socket.data);
|
|
582
|
+
},
|
|
583
|
+
data(socket, buf) {
|
|
584
|
+
socket.data.applyChunk(buf.toString());
|
|
585
|
+
},
|
|
586
|
+
drain(socket) {
|
|
587
|
+
socket.data.drain();
|
|
588
|
+
},
|
|
589
|
+
close(socket) {
|
|
590
|
+
clients.delete(socket.data);
|
|
591
|
+
ctx.detachClient(socket.data);
|
|
592
|
+
},
|
|
593
|
+
error() {},
|
|
594
|
+
},
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
return {
|
|
598
|
+
stop() {
|
|
599
|
+
for (const timer of resizeTimers.values()) {
|
|
600
|
+
clearTimeout(timer);
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
for (const timer of detectTimers.values()) {
|
|
604
|
+
clearTimeout(timer);
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
server.stop(true);
|
|
608
|
+
reporter.stop(true);
|
|
609
|
+
mgr.killAll();
|
|
610
|
+
|
|
611
|
+
for (const model of screens.values()) {
|
|
612
|
+
model.stop();
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
store.stop();
|
|
616
|
+
|
|
617
|
+
if (opts.pidPath !== undefined) {
|
|
618
|
+
try {
|
|
619
|
+
unlinkSync(opts.pidPath);
|
|
620
|
+
} catch {}
|
|
621
|
+
}
|
|
622
|
+
},
|
|
623
|
+
};
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// A session reporting a transcript path that does not exist on disk yet has
|
|
627
|
+
// nothing to resume — claude exits immediately on such a resume, which
|
|
628
|
+
// reads as the revive silently failing. A session that never reported a
|
|
629
|
+
// path (foreign adapters) passes; the spawn itself will tell the truth.
|
|
630
|
+
function hasResumableTranscript(mgr: SessionManager, id: string): boolean {
|
|
631
|
+
const s = mgr.sessions.find((x) => x.id === id);
|
|
632
|
+
|
|
633
|
+
if (s === undefined || s.transcriptSource === undefined) {
|
|
634
|
+
return true;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
return existsSync(s.transcriptSource);
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
function getDescriptor(mgr: SessionManager, id: string): SessionDescriptor {
|
|
641
|
+
const d = mgr.collectDescriptors().find((x) => x.id === id);
|
|
642
|
+
|
|
643
|
+
if (d === undefined) {
|
|
644
|
+
throw new Error(`descriptor for unknown session ${id}`);
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
return d;
|
|
648
|
+
}
|
package/src/dirs.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { basename } from 'node:path';
|
|
4
|
+
|
|
5
|
+
// Spawn-history first (most recently used, reported by the daemon), then
|
|
6
|
+
// zoxide's frecency list.
|
|
7
|
+
export async function collectDirs(recent: readonly string[]): Promise<string[]> {
|
|
8
|
+
let zoxide: string[] = [];
|
|
9
|
+
|
|
10
|
+
try {
|
|
11
|
+
const proc = Bun.spawn(['zoxide', 'query', '-l'], { stdout: 'pipe', stderr: 'ignore' });
|
|
12
|
+
|
|
13
|
+
const text = await new Response(proc.stdout).text();
|
|
14
|
+
|
|
15
|
+
zoxide = text.split('\n').filter((line) => line !== '');
|
|
16
|
+
} catch {}
|
|
17
|
+
|
|
18
|
+
const seen = new Set<string>();
|
|
19
|
+
|
|
20
|
+
const found: string[] = [];
|
|
21
|
+
|
|
22
|
+
for (const d of [...recent, ...zoxide]) {
|
|
23
|
+
if (!seen.has(d) && existsSync(d)) {
|
|
24
|
+
seen.add(d);
|
|
25
|
+
found.push(d);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (found.length === 0) {
|
|
30
|
+
found.push(homedir());
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return found;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function pickMatches(items: readonly string[], filter: string): string[] {
|
|
37
|
+
if (filter === '') {
|
|
38
|
+
return [...items];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const f = filter.toLowerCase();
|
|
42
|
+
|
|
43
|
+
const scored = items
|
|
44
|
+
.map((item) => {
|
|
45
|
+
const base = basename(item).toLowerCase();
|
|
46
|
+
let score = -1;
|
|
47
|
+
|
|
48
|
+
if (base.startsWith(f)) {
|
|
49
|
+
score = 0;
|
|
50
|
+
} else if (base.includes(f)) {
|
|
51
|
+
score = 1;
|
|
52
|
+
} else if (item.toLowerCase().includes(f)) {
|
|
53
|
+
score = 2;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return { item, score };
|
|
57
|
+
})
|
|
58
|
+
.filter((x) => x.score >= 0);
|
|
59
|
+
|
|
60
|
+
return scored.toSorted((a, b) => a.score - b.score).map((x) => x.item);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function formatDir(d: string): string {
|
|
64
|
+
const home = homedir();
|
|
65
|
+
|
|
66
|
+
return d.startsWith(home) ? `~${d.slice(home.length)}` : d;
|
|
67
|
+
}
|