@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.
@@ -0,0 +1,511 @@
1
+ import { basename } from 'node:path';
2
+ import type { Dims } from './attach-registry';
3
+ import { OutboundQueue } from './outbound-queue';
4
+ import type { SocketWriter } from './outbound-queue';
5
+ import type { AnswerResult } from './permission-registry';
6
+ import { MAX_CHUNK, MAX_LINE, PROTOCOL_V, decodeMessage, encodeMessage } from './protocol';
7
+ import type { ErrorCode, EventMsg, RequestMsg } from './protocol';
8
+ import type { FleetEntry, SessionDescriptor } from './sessions';
9
+
10
+ interface SpawnParams {
11
+ readonly cwd: string;
12
+ readonly name: string;
13
+ readonly prompt: string;
14
+ readonly cols: number;
15
+ readonly rows: number;
16
+ readonly resume: boolean | string;
17
+ readonly namedBy: 'user' | 'auto';
18
+ }
19
+
20
+ export interface DaemonContext {
21
+ readonly build: string;
22
+ readonly collectSessions: () => SessionDescriptor[];
23
+ readonly collectSpawnDirs: () => string[];
24
+ readonly collectFleet: () => FleetEntry[];
25
+ readonly spawnSession: (p: SpawnParams) => SessionDescriptor;
26
+ readonly killSession: (id: string) => boolean;
27
+ readonly ackSession: (id: string) => boolean;
28
+ readonly buildResumeCommand: (id: string) => string | null;
29
+ readonly answerPermission: (request: string, decision: string) => AnswerResult;
30
+ readonly restoreFleet: (cols: number, rows: number) => number;
31
+ readonly attachSession: (
32
+ client: OutputClient,
33
+ sessionID: string,
34
+ dims: Dims,
35
+ ) => 'ok' | 'missing' | 'dead';
36
+ readonly detachSession: (client: OutputClient, sessionID: string) => void;
37
+ readonly detachClient: (client: OutputClient) => void;
38
+ readonly writeSessionInput: (
39
+ sessionID: string,
40
+ data: string,
41
+ ) => 'busy' | 'ok' | 'missing' | 'dead';
42
+ readonly ejectSession: (
43
+ id: string,
44
+ prompt: string,
45
+ ) => 'ok' | 'missing' | 'unsupported' | 'no_transcript';
46
+ readonly adoptSession: (
47
+ id: string,
48
+ cols: number,
49
+ rows: number,
50
+ ) => 'ok' | 'missing' | 'no_transcript';
51
+ readonly resizeSession: (client: OutputClient, sessionID: string, dims: Dims) => boolean;
52
+ readonly resyncClient: (sessionID: string, client: OutputClient) => Promise<void>;
53
+ readonly queueBytes?: number;
54
+ readonly getEffectiveDims: (sessionID: string) => Dims;
55
+ }
56
+
57
+ // The slice of a connection the attach bookkeeping needs: identity plus the
58
+ // ability to receive output events.
59
+ export interface OutputClient {
60
+ readonly sendOutput: (sessionID: string, event: EventMsg, byteLength: number) => void;
61
+ }
62
+
63
+ interface PeerSocket extends SocketWriter {
64
+ readonly end: () => void;
65
+ }
66
+
67
+ export class DaemonConnection {
68
+ private readonly peer: PeerSocket;
69
+
70
+ private readonly ctx: DaemonContext;
71
+
72
+ private readonly queue: OutboundQueue;
73
+
74
+ private buffer = '';
75
+
76
+ private helloed = false;
77
+
78
+ private readonly desynced = new Map<string, number>();
79
+
80
+ constructor(peer: PeerSocket, ctx: DaemonContext) {
81
+ this.peer = peer;
82
+ this.ctx = ctx;
83
+
84
+ this.queue = new OutboundQueue(peer, ctx.queueBytes);
85
+ }
86
+
87
+ sendEvent(event: EventMsg): void {
88
+ if (this.helloed && !this.queue.send(encodeMessage(event))) {
89
+ this.peer.end();
90
+ }
91
+ }
92
+
93
+ // Output is droppable: an overflow discards this session's backlog for
94
+ // this client and resynchronizes with a repaint once the queue drains. An
95
+ // intermediate chunk is never dropped without that resync, because a byte
96
+ // stream cut mid-escape corrupts the client's terminal state.
97
+ sendOutput(sessionID: string, event: EventMsg, byteLength: number): void {
98
+ if (!this.helloed) {
99
+ return;
100
+ }
101
+
102
+ const dropped = this.desynced.get(sessionID);
103
+
104
+ if (dropped !== undefined) {
105
+ this.desynced.set(sessionID, dropped + byteLength);
106
+
107
+ return;
108
+ }
109
+
110
+ if (!this.queue.send(encodeMessage(event))) {
111
+ this.desynced.set(sessionID, byteLength);
112
+ }
113
+ }
114
+
115
+ applyChunk(chunk: string): void {
116
+ const buffered = this.buffer + chunk;
117
+
118
+ if (buffered.length > MAX_LINE) {
119
+ this.sendErr(0, 'bad_args', `line exceeds ${MAX_LINE} bytes`);
120
+ this.peer.end();
121
+
122
+ return;
123
+ }
124
+
125
+ const lines = buffered.split('\n');
126
+
127
+ this.buffer = lines.pop() ?? '';
128
+
129
+ for (const line of lines) {
130
+ if (line.trim() === '') {
131
+ continue;
132
+ }
133
+
134
+ if (!this.applyLine(line)) {
135
+ this.peer.end();
136
+
137
+ return;
138
+ }
139
+ }
140
+ }
141
+
142
+ drain(): void {
143
+ this.queue.drain();
144
+
145
+ if (this.queue.queuedBytes > 0 || this.desynced.size === 0) {
146
+ return;
147
+ }
148
+
149
+ for (const [sessionID, dropped] of this.desynced) {
150
+ this.desynced.delete(sessionID);
151
+ this.sendEvent({ v: PROTOCOL_V, ev: 'session.desync', s: sessionID, dropped });
152
+ void this.ctx.resyncClient(sessionID, this);
153
+ }
154
+ }
155
+
156
+ // false: the connection is beyond recovery and gets closed.
157
+ private applyLine(line: string): boolean {
158
+ const decoded = decodeMessage(line);
159
+
160
+ if (decoded.kind === 'malformed') {
161
+ this.sendErr(0, 'bad_args', `malformed line: ${decoded.reason}`);
162
+
163
+ return false;
164
+ }
165
+
166
+ if (decoded.kind !== 'request') {
167
+ this.sendErr(0, 'bad_args', 'only requests flow client to daemon');
168
+
169
+ return false;
170
+ }
171
+
172
+ const req = decoded.msg;
173
+
174
+ if (req.m === 'daemon.hello') {
175
+ return this.applyHello(req);
176
+ }
177
+
178
+ if (!this.helloed) {
179
+ this.sendErr(req.id, 'unauthorized', 'daemon.hello must be the first request');
180
+
181
+ return false;
182
+ }
183
+
184
+ this.applyRequest(req);
185
+
186
+ return true;
187
+ }
188
+
189
+ private applyRequest(req: RequestMsg): void {
190
+ switch (req.m) {
191
+ case 'daemon.ping': {
192
+ this.sendOk(req.id, {});
193
+
194
+ return;
195
+ }
196
+ case 'session.list': {
197
+ this.sendOk(req.id, { sessions: this.ctx.collectSessions() });
198
+
199
+ return;
200
+ }
201
+ case 'dirs.list': {
202
+ this.sendOk(req.id, { dirs: this.ctx.collectSpawnDirs() });
203
+
204
+ return;
205
+ }
206
+ case 'fleet.list': {
207
+ this.sendOk(req.id, { fleet: this.ctx.collectFleet() });
208
+
209
+ return;
210
+ }
211
+ case 'session.spawn': {
212
+ this.applySpawn(req);
213
+
214
+ return;
215
+ }
216
+ case 'session.kill': {
217
+ this.applySessionVerb(req, this.ctx.killSession);
218
+
219
+ return;
220
+ }
221
+ case 'session.ack': {
222
+ this.applySessionVerb(req, this.ctx.ackSession);
223
+
224
+ return;
225
+ }
226
+ case 'session.resumeCommand': {
227
+ const id = typeof req.p?.['session'] === 'string' ? req.p['session'] : '';
228
+ const command = this.ctx.buildResumeCommand(id);
229
+
230
+ if (command === null) {
231
+ this.sendErr(req.id, 'no_such_session', `no session '${id}'`);
232
+ } else {
233
+ this.sendOk(req.id, { command });
234
+ }
235
+
236
+ return;
237
+ }
238
+ case 'session.eject': {
239
+ const sessionID = typeof req.p?.['session'] === 'string' ? req.p['session'] : '';
240
+
241
+ const prompt =
242
+ typeof req.p?.['prompt'] === 'string' && req.p['prompt'] !== ''
243
+ ? req.p['prompt']
244
+ : 'Continue the task autonomously. Verify your work as you go and stop when it is complete.';
245
+
246
+ const result = this.ctx.ejectSession(sessionID, prompt);
247
+
248
+ if (result === 'ok') {
249
+ this.sendOk(req.id, {});
250
+ } else if (result === 'unsupported') {
251
+ this.sendErr(req.id, 'unsupported', 'this daemon has no headless runner');
252
+ } else if (result === 'no_transcript') {
253
+ this.sendErr(
254
+ req.id,
255
+ 'session_dead',
256
+ 'nothing to resume yet — the session has no saved transcript',
257
+ );
258
+ } else {
259
+ this.sendErr(
260
+ req.id,
261
+ 'no_such_session',
262
+ `session '${sessionID}' has no live terminal with a captured agent session id`,
263
+ );
264
+ }
265
+
266
+ return;
267
+ }
268
+ case 'session.adopt': {
269
+ const sessionID = typeof req.p?.['session'] === 'string' ? req.p['session'] : '';
270
+ const cols = typeof req.p?.['cols'] === 'number' ? req.p['cols'] : 80;
271
+ const rows = typeof req.p?.['rows'] === 'number' ? req.p['rows'] : 24;
272
+ const adoptResult = this.ctx.adoptSession(sessionID, cols, rows);
273
+
274
+ if (adoptResult === 'ok') {
275
+ this.sendOk(req.id, {});
276
+ } else if (adoptResult === 'no_transcript') {
277
+ this.sendErr(
278
+ req.id,
279
+ 'session_dead',
280
+ 'nothing to resume yet — the session has no saved transcript',
281
+ );
282
+ } else {
283
+ this.sendErr(
284
+ req.id,
285
+ 'no_such_session',
286
+ `session '${sessionID}' is not a dead or headless session with a captured agent session id`,
287
+ );
288
+ }
289
+
290
+ return;
291
+ }
292
+ case 'session.attach': {
293
+ this.applyAttach(req);
294
+
295
+ return;
296
+ }
297
+ case 'session.detach': {
298
+ const sessionID = typeof req.p?.['session'] === 'string' ? req.p['session'] : '';
299
+
300
+ this.ctx.detachSession(this, sessionID);
301
+ this.sendOk(req.id, {});
302
+
303
+ return;
304
+ }
305
+ case 'session.input': {
306
+ this.applyInput(req);
307
+
308
+ return;
309
+ }
310
+ case 'session.resize': {
311
+ this.applyResize(req);
312
+
313
+ return;
314
+ }
315
+ case 'permission.respond': {
316
+ this.applyPermissionRespond(req);
317
+
318
+ return;
319
+ }
320
+ case 'fleet.restore': {
321
+ const cols = typeof req.p?.['cols'] === 'number' ? req.p['cols'] : 80;
322
+ const rows = typeof req.p?.['rows'] === 'number' ? req.p['rows'] : 24;
323
+
324
+ this.sendOk(req.id, { restored: this.ctx.restoreFleet(cols, rows) });
325
+
326
+ return;
327
+ }
328
+ default: {
329
+ this.sendErr(req.id, 'unknown_method', `unknown method '${req.m}'`);
330
+ }
331
+ }
332
+ }
333
+
334
+ private applySpawn(req: RequestMsg): void {
335
+ const cwd = req.p?.['cwd'];
336
+
337
+ if (typeof cwd !== 'string' || cwd === '') {
338
+ this.sendErr(req.id, 'bad_args', 'session.spawn requires a cwd');
339
+
340
+ return;
341
+ }
342
+
343
+ const name = typeof req.p?.['name'] === 'string' ? req.p['name'] : '';
344
+ const rawResume = req.p?.['resume'];
345
+ let resume: boolean | string = false;
346
+
347
+ if (typeof rawResume === 'boolean' || typeof rawResume === 'string') {
348
+ resume = rawResume;
349
+ }
350
+
351
+ const session = this.ctx.spawnSession({
352
+ cwd,
353
+ name: name === '' ? basename(cwd) : name,
354
+ prompt: typeof req.p?.['prompt'] === 'string' ? req.p['prompt'] : '',
355
+ cols: typeof req.p?.['cols'] === 'number' ? req.p['cols'] : 80,
356
+ rows: typeof req.p?.['rows'] === 'number' ? req.p['rows'] : 24,
357
+ resume,
358
+ namedBy: name === '' ? 'auto' : 'user',
359
+ });
360
+
361
+ this.sendOk(req.id, { session });
362
+ }
363
+
364
+ private applyAttach(req: RequestMsg): void {
365
+ const sessionID = typeof req.p?.['session'] === 'string' ? req.p['session'] : '';
366
+ const cols = typeof req.p?.['cols'] === 'number' ? req.p['cols'] : 80;
367
+ const rows = typeof req.p?.['rows'] === 'number' ? req.p['rows'] : 24;
368
+ const result = this.ctx.attachSession(this, sessionID, { cols, rows });
369
+
370
+ if (result === 'missing') {
371
+ this.sendErr(req.id, 'no_such_session', `no session '${sessionID}'`);
372
+
373
+ return;
374
+ }
375
+
376
+ if (result === 'dead') {
377
+ this.sendErr(req.id, 'session_dead', `session '${sessionID}' has no live process`);
378
+
379
+ return;
380
+ }
381
+
382
+ const dims = this.ctx.getEffectiveDims(sessionID);
383
+
384
+ this.sendOk(req.id, { cols: dims.cols, rows: dims.rows });
385
+ }
386
+
387
+ private applyInput(req: RequestMsg): void {
388
+ const sessionID = typeof req.p?.['session'] === 'string' ? req.p['session'] : '';
389
+ const data = typeof req.p?.['d'] === 'string' ? req.p['d'] : '';
390
+ const result = this.ctx.writeSessionInput(sessionID, data);
391
+
392
+ if (result === 'missing') {
393
+ this.sendErr(req.id, 'no_such_session', `no session '${sessionID}'`);
394
+
395
+ return;
396
+ }
397
+
398
+ if (result === 'dead') {
399
+ this.sendErr(req.id, 'session_dead', `session '${sessionID}' has no live process`);
400
+
401
+ return;
402
+ }
403
+
404
+ if (result === 'busy') {
405
+ this.sendErr(
406
+ req.id,
407
+ 'too_slow',
408
+ `session '${sessionID}' is mid-run; wait for the turn to end`,
409
+ );
410
+
411
+ return;
412
+ }
413
+
414
+ this.sendOk(req.id, {});
415
+ }
416
+
417
+ private applyResize(req: RequestMsg): void {
418
+ const sessionID = typeof req.p?.['session'] === 'string' ? req.p['session'] : '';
419
+ const cols = typeof req.p?.['cols'] === 'number' ? req.p['cols'] : 0;
420
+ const rows = typeof req.p?.['rows'] === 'number' ? req.p['rows'] : 0;
421
+
422
+ if (cols < 1 || rows < 1) {
423
+ this.sendErr(req.id, 'bad_args', 'session.resize requires positive cols and rows');
424
+
425
+ return;
426
+ }
427
+
428
+ if (!this.ctx.resizeSession(this, sessionID, { cols, rows })) {
429
+ this.sendErr(req.id, 'bad_args', `not attached to session '${sessionID}'`);
430
+
431
+ return;
432
+ }
433
+
434
+ this.sendOk(req.id, {});
435
+ }
436
+
437
+ private applyPermissionRespond(req: RequestMsg): void {
438
+ const request = typeof req.p?.['request'] === 'string' ? req.p['request'] : '';
439
+ const decision = typeof req.p?.['decision'] === 'string' ? req.p['decision'] : '';
440
+
441
+ if (request === '' || decision === '') {
442
+ this.sendErr(req.id, 'bad_args', 'permission.respond requires a request and a decision');
443
+
444
+ return;
445
+ }
446
+
447
+ const result = this.ctx.answerPermission(request, decision);
448
+
449
+ switch (result) {
450
+ case 'ok': {
451
+ this.sendOk(req.id, {});
452
+
453
+ return;
454
+ }
455
+ case 'already_answered': {
456
+ this.sendErr(req.id, 'already_answered', `request '${request}' was already answered`);
457
+
458
+ return;
459
+ }
460
+ case 'unsupported': {
461
+ this.sendErr(req.id, 'unsupported', `request '${request}' is answered with keystrokes`);
462
+
463
+ return;
464
+ }
465
+ case 'unknown': {
466
+ this.sendErr(req.id, 'bad_args', `unknown permission request '${request}'`);
467
+ }
468
+ }
469
+ }
470
+
471
+ private applySessionVerb(req: RequestMsg, verb: (id: string) => boolean): void {
472
+ const id = typeof req.p?.['session'] === 'string' ? req.p['session'] : '';
473
+
474
+ if (verb(id)) {
475
+ this.sendOk(req.id, {});
476
+ } else {
477
+ this.sendErr(req.id, 'no_such_session', `no session '${id}'`);
478
+ }
479
+ }
480
+
481
+ private applyHello(req: RequestMsg): boolean {
482
+ const client = typeof req.p?.['client'] === 'string' ? req.p['client'] : 'unknown client';
483
+
484
+ if (req.v !== PROTOCOL_V) {
485
+ this.sendErr(
486
+ req.id,
487
+ 'protocol_mismatch',
488
+ `${client} speaks protocol v${req.v}, daemon ${this.ctx.build} speaks v${PROTOCOL_V}; restart the daemon so both run the same build`,
489
+ );
490
+
491
+ return false;
492
+ }
493
+
494
+ this.helloed = true;
495
+
496
+ this.sendOk(req.id, {
497
+ daemon: this.ctx.build,
498
+ limits: { maxLine: MAX_LINE, maxChunk: MAX_CHUNK },
499
+ });
500
+
501
+ return true;
502
+ }
503
+
504
+ private sendOk(id: number, ok: Readonly<Record<string, unknown>>): void {
505
+ this.queue.send(encodeMessage({ v: PROTOCOL_V, id, ok }));
506
+ }
507
+
508
+ private sendErr(id: number, code: ErrorCode, msg: string): void {
509
+ this.queue.send(encodeMessage({ v: PROTOCOL_V, id, err: { code, msg } }));
510
+ }
511
+ }
@@ -0,0 +1,16 @@
1
+ import type { ErrorCode } from './protocol';
2
+
3
+ /**
4
+ * A protocol-level error answer from the daemon, thrown to reject the
5
+ * request that caused it.
6
+ */
7
+ export class DaemonError extends Error {
8
+ readonly code: ErrorCode;
9
+
10
+ constructor(code: ErrorCode, msg: string) {
11
+ super(msg);
12
+
13
+ this.code = code;
14
+ this.name = 'DaemonError';
15
+ }
16
+ }