@zgeoff/atc 1.0.3 → 2.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/README.md +77 -183
- package/package.json +5 -1
- package/src/cli.ts +14 -0
- package/src/client/index.ts +9 -9
- package/src/client/parse-daemon-event.ts +26 -26
- package/src/daemon/attach-registry.ts +4 -3
- package/src/daemon/build-session-event.ts +4 -4
- package/src/daemon/daemon-connection.ts +26 -1
- package/src/daemon/daemon.ts +103 -21
- package/src/daemon/make-hook-runner.ts +84 -0
- package/src/daemon/screen-model.ts +42 -11
- package/src/daemon/start-events-server.ts +97 -0
- package/src/events.ts +33 -0
- package/src/mcp-server.ts +11 -0
- package/src/protocol/protocol.ts +1 -1
- package/src/protocol/request-param-schemas.ts +1 -0
- package/src/shared/collect-hooks.ts +90 -0
- package/src/shared/config.ts +8 -1
|
@@ -15,6 +15,7 @@ import type { SessionID } from '../shared/session-id';
|
|
|
15
15
|
import type { FleetEntry } from '../store/fleet-entry';
|
|
16
16
|
import type { Dims } from './attach-registry';
|
|
17
17
|
import type { AnswerResult } from './permission-registry';
|
|
18
|
+
import type { ScreenText } from './screen-model';
|
|
18
19
|
import type { SessionDescriptor } from './sessions';
|
|
19
20
|
|
|
20
21
|
interface SpawnParams {
|
|
@@ -41,6 +42,7 @@ export interface DaemonContext {
|
|
|
41
42
|
readonly quitDaemon: () => void;
|
|
42
43
|
readonly ackSession: (id: SessionID) => boolean;
|
|
43
44
|
readonly buildResumeCommand: (id: SessionID) => string | null;
|
|
45
|
+
readonly readSessionScreen: (id: SessionID) => Promise<ScreenText | 'missing' | 'no_screen'>;
|
|
44
46
|
readonly answerPermission: (request: string, decision: string) => AnswerResult;
|
|
45
47
|
readonly restoreFleet: (cols: number, rows: number) => Promise<number>;
|
|
46
48
|
readonly attachSession: (
|
|
@@ -168,7 +170,7 @@ export class DaemonConnection {
|
|
|
168
170
|
|
|
169
171
|
for (const [sessionID, dropped] of this.desynced) {
|
|
170
172
|
this.desynced.delete(sessionID);
|
|
171
|
-
this.sendEvent({ v: PROTOCOL_V, ev: '
|
|
173
|
+
this.sendEvent({ v: PROTOCOL_V, ev: 'SessionDesync', s: sessionID, dropped });
|
|
172
174
|
void this.ctx.resyncClient(sessionID, this);
|
|
173
175
|
}
|
|
174
176
|
}
|
|
@@ -304,6 +306,29 @@ export class DaemonConnection {
|
|
|
304
306
|
|
|
305
307
|
return;
|
|
306
308
|
}
|
|
309
|
+
case 'session.screen': {
|
|
310
|
+
const parsed = parseRequestParams('session.screen', req.p);
|
|
311
|
+
|
|
312
|
+
if (!parsed.ok) {
|
|
313
|
+
this.sendErr(req.id, 'bad_args', parsed.message);
|
|
314
|
+
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const id = parsed.data.session;
|
|
319
|
+
|
|
320
|
+
const screen = await this.ctx.readSessionScreen(id);
|
|
321
|
+
|
|
322
|
+
if (screen === 'missing') {
|
|
323
|
+
this.sendErr(req.id, 'no_such_session', `no session '${id}'`);
|
|
324
|
+
} else if (screen === 'no_screen') {
|
|
325
|
+
this.sendErr(req.id, 'session_dead', `session '${id}' has no captured screen`);
|
|
326
|
+
} else {
|
|
327
|
+
this.sendOk(req.id, { ...screen });
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
307
332
|
case 'session.eject': {
|
|
308
333
|
const parsed = parseRequestParams('session.eject', req.p);
|
|
309
334
|
|
package/src/daemon/daemon.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { unlinkSync, writeFileSync } from 'node:fs';
|
|
|
2
2
|
import type { AgentAdapter } from '../agents/agent-adapter';
|
|
3
3
|
import { MAX_CHUNK, PROTOCOL_V } from '../protocol/protocol';
|
|
4
4
|
import type { EventMsg } from '../protocol/protocol';
|
|
5
|
+
import type { HooksConfig } from '../shared/collect-hooks';
|
|
5
6
|
import type { SessionID } from '../shared/session-id';
|
|
6
7
|
import { StateStore } from '../store/state-store';
|
|
7
8
|
import { AttachRegistry } from './attach-registry';
|
|
@@ -9,6 +10,8 @@ import { buildSessionEvent } from './build-session-event';
|
|
|
9
10
|
import { DaemonConnection } from './daemon-connection';
|
|
10
11
|
import type { DaemonContext, OutputClient } from './daemon-connection';
|
|
11
12
|
import { startHookServer } from './hooks';
|
|
13
|
+
import { makeHookRunner } from './make-hook-runner';
|
|
14
|
+
import type { HookScope } from './make-hook-runner';
|
|
12
15
|
import { PermissionRegistry } from './permission-registry';
|
|
13
16
|
import { restoreFleet } from './restore-fleet';
|
|
14
17
|
import { runEjectHandoff } from './run-eject-handoff';
|
|
@@ -16,6 +19,7 @@ import { ScreenModel } from './screen-model';
|
|
|
16
19
|
import { SessionRuntime } from './session-runtime';
|
|
17
20
|
import { SessionManager } from './sessions';
|
|
18
21
|
import type { SessionDescriptor, SessionState } from './sessions';
|
|
22
|
+
import { startEventsServer } from './start-events-server';
|
|
19
23
|
import { startHeadlessTurn } from './start-headless-turn';
|
|
20
24
|
|
|
21
25
|
export interface DaemonOptions {
|
|
@@ -39,6 +43,14 @@ export interface DaemonOptions {
|
|
|
39
43
|
// When set, the daemon's pid is written here and removed on stop.
|
|
40
44
|
readonly pidPath?: string;
|
|
41
45
|
|
|
46
|
+
// When set, a read-only events socket listens here and streams every
|
|
47
|
+
// broadcast event as NDJSON to any subscriber, no handshake required.
|
|
48
|
+
readonly eventsSocketPath?: string;
|
|
49
|
+
|
|
50
|
+
// User-configured hooks, keyed by wire-event name; each broadcast event
|
|
51
|
+
// fires its matching commands, observational and fire-and-forget.
|
|
52
|
+
readonly hooks?: HooksConfig;
|
|
53
|
+
|
|
42
54
|
// Where the statusline contract file is written; defaults to the real one.
|
|
43
55
|
readonly statusPath?: string;
|
|
44
56
|
|
|
@@ -86,27 +98,55 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
86
98
|
const mgr = new SessionManager(opts.adapter, store, opts.statusPath, opts.adapters ?? []);
|
|
87
99
|
const clients = new Set<DaemonConnection>();
|
|
88
100
|
|
|
89
|
-
const
|
|
101
|
+
const runHooks = makeHookRunner(opts.hooks ?? {});
|
|
102
|
+
|
|
103
|
+
const eventsServer =
|
|
104
|
+
opts.eventsSocketPath === undefined
|
|
105
|
+
? null
|
|
106
|
+
: startEventsServer({
|
|
107
|
+
socketPath: opts.eventsSocketPath,
|
|
108
|
+
collectSnapshot: () =>
|
|
109
|
+
mgr.collectDescriptors().map((session) => ({
|
|
110
|
+
v: PROTOCOL_V,
|
|
111
|
+
ev: 'SessionAdded',
|
|
112
|
+
session,
|
|
113
|
+
})),
|
|
114
|
+
...(opts.queueBytes === undefined ? {} : { queueBytes: opts.queueBytes }),
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
const emitEvent = (event: EventMsg, scope: HookScope | null = null) => {
|
|
90
118
|
for (const client of clients) {
|
|
91
119
|
client.sendEvent(event);
|
|
92
120
|
}
|
|
121
|
+
|
|
122
|
+
eventsServer?.broadcast(event);
|
|
123
|
+
runHooks(event, scope);
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const findHookScope = (sessionID: SessionID): HookScope | null => {
|
|
127
|
+
const s = mgr.sessions.find((x) => x.id === sessionID);
|
|
128
|
+
|
|
129
|
+
return s === undefined ? null : { cwd: s.cwd, repoRoot: s.repoRoot };
|
|
93
130
|
};
|
|
94
131
|
|
|
95
132
|
const registry = new PermissionRegistry();
|
|
96
133
|
|
|
97
134
|
registry.onRequested = (req) => {
|
|
98
|
-
emitEvent(
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
135
|
+
emitEvent(
|
|
136
|
+
{
|
|
137
|
+
v: PROTOCOL_V,
|
|
138
|
+
ev: 'PermissionRequested',
|
|
139
|
+
request: req.id,
|
|
140
|
+
s: req.sessionID,
|
|
141
|
+
message: req.message,
|
|
142
|
+
respondable: req.respondable,
|
|
143
|
+
},
|
|
144
|
+
findHookScope(req.sessionID),
|
|
145
|
+
);
|
|
106
146
|
};
|
|
107
147
|
|
|
108
148
|
registry.onResolved = (id, decision) => {
|
|
109
|
-
emitEvent({ v: PROTOCOL_V, ev: '
|
|
149
|
+
emitEvent({ v: PROTOCOL_V, ev: 'PermissionResolved', request: id, decision });
|
|
110
150
|
};
|
|
111
151
|
|
|
112
152
|
// One runtime per live session: its screen model, output sequence
|
|
@@ -161,7 +201,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
161
201
|
|
|
162
202
|
client.sendOutput(
|
|
163
203
|
sessionID,
|
|
164
|
-
{ v: PROTOCOL_V, ev: '
|
|
204
|
+
{ v: PROTOCOL_V, ev: 'SessionOutput', s: sessionID, seq, d: chunk },
|
|
165
205
|
chunk.length,
|
|
166
206
|
);
|
|
167
207
|
}
|
|
@@ -190,13 +230,16 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
190
230
|
runtime.dims = dims;
|
|
191
231
|
}
|
|
192
232
|
|
|
193
|
-
emitEvent(
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
233
|
+
emitEvent(
|
|
234
|
+
{
|
|
235
|
+
v: PROTOCOL_V,
|
|
236
|
+
ev: 'SessionResized',
|
|
237
|
+
s: sessionID,
|
|
238
|
+
cols: dims.cols,
|
|
239
|
+
rows: dims.rows,
|
|
240
|
+
},
|
|
241
|
+
findHookScope(sessionID),
|
|
242
|
+
);
|
|
200
243
|
};
|
|
201
244
|
|
|
202
245
|
// Debounced so two clients resizing in opposite directions cannot produce
|
|
@@ -258,7 +301,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
258
301
|
|
|
259
302
|
seq++;
|
|
260
303
|
|
|
261
|
-
const event: EventMsg = { v: PROTOCOL_V, ev: '
|
|
304
|
+
const event: EventMsg = { v: PROTOCOL_V, ev: 'SessionOutput', s: s.id, seq, d: chunk };
|
|
262
305
|
|
|
263
306
|
for (const conn of conns) {
|
|
264
307
|
conn.sendOutput(s.id, event, chunk.length);
|
|
@@ -270,6 +313,20 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
270
313
|
}
|
|
271
314
|
};
|
|
272
315
|
|
|
316
|
+
// A detach can outlive its session — a kill removes the session before the
|
|
317
|
+
// client's connection closes — and a gone session already broadcast
|
|
318
|
+
// SessionRemoved, so a descriptor miss emits nothing.
|
|
319
|
+
const emitSessionDetached = (sessionID: SessionID) => {
|
|
320
|
+
const session = mgr.collectDescriptors().find((x) => x.id === sessionID);
|
|
321
|
+
|
|
322
|
+
if (session !== undefined) {
|
|
323
|
+
emitEvent(
|
|
324
|
+
{ v: PROTOCOL_V, ev: 'SessionDetached', session },
|
|
325
|
+
{ cwd: session.cwd, repoRoot: session.repoRoot },
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
};
|
|
329
|
+
|
|
273
330
|
// Permission requests are synthesized from attention transitions: entering
|
|
274
331
|
// needs_you opens one, and leaving it (answered directly in the terminal,
|
|
275
332
|
// or the session dying) dismisses whatever is pending. Each session's
|
|
@@ -319,7 +376,9 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
319
376
|
const event = buildSessionEvent(mgr, kind, s);
|
|
320
377
|
|
|
321
378
|
if (event !== null) {
|
|
322
|
-
|
|
379
|
+
// The scope comes from the session object rather than a descriptor
|
|
380
|
+
// lookup, so dir-filtered hooks still fire for a removed session.
|
|
381
|
+
emitEvent(event, { cwd: s.cwd, repoRoot: s.repoRoot });
|
|
323
382
|
}
|
|
324
383
|
};
|
|
325
384
|
|
|
@@ -466,6 +525,18 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
466
525
|
return true;
|
|
467
526
|
},
|
|
468
527
|
buildResumeCommand: (id) => mgr.buildResumeCommand(id),
|
|
528
|
+
|
|
529
|
+
// A killed session keeps its last screen until a second kill removes
|
|
530
|
+
// it, so a reader can still see what the agent printed before it died.
|
|
531
|
+
readSessionScreen: (id) => {
|
|
532
|
+
if (!mgr.sessions.some((x) => x.id === id)) {
|
|
533
|
+
return Promise.resolve('missing');
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
const screen = runtimes.get(id)?.screen ?? null;
|
|
537
|
+
|
|
538
|
+
return screen === null ? Promise.resolve('no_screen') : screen.renderText();
|
|
539
|
+
},
|
|
469
540
|
answerPermission: (request, decision) => registry.answer(request, decision),
|
|
470
541
|
attachSession: (client, sessionID, dims) => {
|
|
471
542
|
const s = mgr.sessions.find((x) => x.id === sessionID);
|
|
@@ -488,17 +559,27 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
488
559
|
// only redraws its live region, never the rows above it.
|
|
489
560
|
applyEffectiveDims(sessionID);
|
|
490
561
|
void sendReplay(sessionID, client);
|
|
562
|
+
const attached = getDescriptor(mgr, sessionID);
|
|
563
|
+
|
|
564
|
+
emitEvent(
|
|
565
|
+
{ v: PROTOCOL_V, ev: 'SessionAttached', session: attached },
|
|
566
|
+
{ cwd: attached.cwd, repoRoot: attached.repoRoot },
|
|
567
|
+
);
|
|
491
568
|
|
|
492
569
|
return 'ok';
|
|
493
570
|
},
|
|
494
571
|
detachSession: (client, sessionID) => {
|
|
495
|
-
attachments.detach(sessionID, client)
|
|
572
|
+
if (!attachments.detach(sessionID, client)) {
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
496
575
|
|
|
497
576
|
scheduleResize(sessionID);
|
|
577
|
+
emitSessionDetached(sessionID);
|
|
498
578
|
},
|
|
499
579
|
detachClient: (client) => {
|
|
500
580
|
for (const sessionID of attachments.detachAll(client)) {
|
|
501
581
|
scheduleResize(sessionID);
|
|
582
|
+
emitSessionDetached(sessionID);
|
|
502
583
|
}
|
|
503
584
|
},
|
|
504
585
|
writeSessionInput: (sessionID, data) => {
|
|
@@ -570,6 +651,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
|
|
|
570
651
|
|
|
571
652
|
stopDaemon = async () => {
|
|
572
653
|
server.stop(true);
|
|
654
|
+
eventsServer?.stop();
|
|
573
655
|
reporter.stop(true);
|
|
574
656
|
mgr.killAll();
|
|
575
657
|
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import type { EventMsg } from '../protocol/protocol';
|
|
2
|
+
import type { HookEntry, HooksConfig } from '../shared/collect-hooks';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The session paths a hook's `dir` filter is matched against. Null when the
|
|
6
|
+
* event carries no session, in which case only unfiltered hooks fire.
|
|
7
|
+
*/
|
|
8
|
+
export interface HookScope {
|
|
9
|
+
readonly cwd: string;
|
|
10
|
+
readonly repoRoot: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type RunHooks = (event: EventMsg, scope: HookScope | null) => void;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Builds the daemon's hook runner. Each call fires every configured command
|
|
17
|
+
* for the event's name, with the wire-event JSON on stdin and the event name
|
|
18
|
+
* in `ATC_EVENT`. Hooks are observational and fire-and-forget: the daemon
|
|
19
|
+
* never waits on one, a run past its timeout is killed, and a nonzero exit
|
|
20
|
+
* or spawn failure is logged to stderr and otherwise ignored.
|
|
21
|
+
*/
|
|
22
|
+
export function makeHookRunner(hooks: HooksConfig): RunHooks {
|
|
23
|
+
return (event, scope) => {
|
|
24
|
+
for (const entry of hooks[event.ev] ?? []) {
|
|
25
|
+
if (entry.dir === undefined || isInScope(entry.dir, scope)) {
|
|
26
|
+
runHook(entry, event);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function isInScope(dir: string, scope: HookScope | null): boolean {
|
|
33
|
+
if (scope === null) {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return isUnderDir(scope.repoRoot, dir) || isUnderDir(scope.cwd, dir);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Path-segment containment: /a/b contains /a/b and /a/b/c, never /a/bc.
|
|
41
|
+
function isUnderDir(candidate: string, dir: string): boolean {
|
|
42
|
+
const prefix = dir === '/' ? '/' : `${dir}/`;
|
|
43
|
+
|
|
44
|
+
return candidate === dir || candidate.startsWith(prefix);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const DEFAULT_TIMEOUT_MS = 10_000;
|
|
48
|
+
|
|
49
|
+
function runHook(entry: HookEntry, event: EventMsg): void {
|
|
50
|
+
let proc: ReturnType<typeof Bun.spawn>;
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
proc = Bun.spawn(['/bin/sh', '-c', entry.command], {
|
|
54
|
+
stdin: Buffer.from(`${JSON.stringify(event)}\n`),
|
|
55
|
+
stdout: 'ignore',
|
|
56
|
+
stderr: 'ignore',
|
|
57
|
+
env: { ...process.env, ATC_EVENT: event.ev },
|
|
58
|
+
});
|
|
59
|
+
} catch (error) {
|
|
60
|
+
console.error(`atc hook for ${event.ev} failed to spawn: ${String(error)}`);
|
|
61
|
+
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Unref'd so an in-flight hook never keeps the daemon process alive; a
|
|
66
|
+
// hook orphaned by daemon exit is on its own.
|
|
67
|
+
const timer = setTimeout(() => {
|
|
68
|
+
proc.kill();
|
|
69
|
+
}, entry.timeout ?? DEFAULT_TIMEOUT_MS);
|
|
70
|
+
|
|
71
|
+
timer.unref();
|
|
72
|
+
|
|
73
|
+
void (async () => {
|
|
74
|
+
try {
|
|
75
|
+
const code = await proc.exited;
|
|
76
|
+
|
|
77
|
+
if (code !== 0) {
|
|
78
|
+
console.error(`atc hook for ${event.ev} exited ${code}`);
|
|
79
|
+
}
|
|
80
|
+
} finally {
|
|
81
|
+
clearTimeout(timer);
|
|
82
|
+
}
|
|
83
|
+
})();
|
|
84
|
+
}
|
|
@@ -2,6 +2,12 @@ import { SerializeAddon } from '@xterm/addon-serialize';
|
|
|
2
2
|
import { Terminal } from '@xterm/headless';
|
|
3
3
|
import { RESET_INPUT_MODES } from '../shared/reset-input-modes';
|
|
4
4
|
|
|
5
|
+
export interface ScreenText {
|
|
6
|
+
readonly text: string;
|
|
7
|
+
readonly cols: number;
|
|
8
|
+
readonly rows: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
5
11
|
// The serializer already re-emits the modes the vt engine models (mouse
|
|
6
12
|
// tracking, bracketed paste, focus events); these are the ones it drops.
|
|
7
13
|
const REPLAYED_DEC_MODES = new Set([1006, 1007, 2031]);
|
|
@@ -84,21 +90,34 @@ export class ScreenModel {
|
|
|
84
90
|
});
|
|
85
91
|
}
|
|
86
92
|
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
// clients, and the replay's leading clear would erase them from the
|
|
91
|
-
// client's screen for good.
|
|
93
|
+
// Serializing before the flush drains would omit bytes already streamed
|
|
94
|
+
// live to clients, and the replay's leading clear would erase them from
|
|
95
|
+
// the client's screen for good.
|
|
92
96
|
async renderReplay(): Promise<string> {
|
|
93
|
-
|
|
97
|
+
await this.waitForFlush();
|
|
94
98
|
|
|
95
|
-
|
|
96
|
-
|
|
99
|
+
return RESET_INPUT_MODES + this.renderVisibleScreen() + this.renderInputModes();
|
|
100
|
+
}
|
|
97
101
|
|
|
98
|
-
|
|
99
|
-
|
|
102
|
+
// The visible rows of whichever buffer the session is showing, as plain
|
|
103
|
+
// text with no escape sequences: one line per row, trailing blanks
|
|
104
|
+
// trimmed from each row, trailing blank rows dropped. Drains pending
|
|
105
|
+
// writes first, so text recorded just before the read is never missing.
|
|
106
|
+
async renderText(): Promise<ScreenText> {
|
|
107
|
+
await this.waitForFlush();
|
|
100
108
|
|
|
101
|
-
|
|
109
|
+
const buffer = this.term.buffer.active;
|
|
110
|
+
const lines: string[] = [];
|
|
111
|
+
|
|
112
|
+
for (let y = 0; y < this.term.rows; y++) {
|
|
113
|
+
lines.push((buffer.getLine(buffer.baseY + y)?.translateToString(true) ?? '').trimEnd());
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
while (lines.length > 0 && lines.at(-1) === '') {
|
|
117
|
+
lines.pop();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return { text: lines.join('\n'), cols: this.term.cols, rows: this.term.rows };
|
|
102
121
|
}
|
|
103
122
|
|
|
104
123
|
updateDims(cols: number, rows: number): void {
|
|
@@ -109,6 +128,18 @@ export class ScreenModel {
|
|
|
109
128
|
this.term.dispose();
|
|
110
129
|
}
|
|
111
130
|
|
|
131
|
+
// The terminal parses asynchronously, and bytes recorded while a flush is
|
|
132
|
+
// awaited re-arm it — so this drains until no newer write is pending.
|
|
133
|
+
private async waitForFlush(): Promise<void> {
|
|
134
|
+
let pending: Promise<void>;
|
|
135
|
+
|
|
136
|
+
do {
|
|
137
|
+
pending = this.flushed;
|
|
138
|
+
|
|
139
|
+
await pending;
|
|
140
|
+
} while (pending !== this.flushed);
|
|
141
|
+
}
|
|
142
|
+
|
|
112
143
|
// A session on the alternate screen serializes as the normal buffer, a
|
|
113
144
|
// buffer switch, then the alternate buffer. The switch clears nothing on a
|
|
114
145
|
// terminal already in alternate mode — and the client always is, since its
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { unlinkSync } from 'node:fs';
|
|
2
|
+
import { OutboundQueue } from '../protocol/outbound-queue';
|
|
3
|
+
import { encodeMessage } from '../protocol/protocol';
|
|
4
|
+
import type { EventMsg } from '../protocol/protocol';
|
|
5
|
+
|
|
6
|
+
interface EventsServerOptions {
|
|
7
|
+
readonly socketPath: string;
|
|
8
|
+
|
|
9
|
+
// Events replayed to a subscriber the moment it connects, ahead of any
|
|
10
|
+
// live broadcast, so a subscriber learns the current fleet without
|
|
11
|
+
// speaking the client protocol.
|
|
12
|
+
readonly collectSnapshot: () => readonly EventMsg[];
|
|
13
|
+
|
|
14
|
+
// Outbound queue capacity per subscriber; small values force the overflow
|
|
15
|
+
// disconnect in tests.
|
|
16
|
+
readonly queueBytes?: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface EventsServer {
|
|
20
|
+
readonly broadcast: (event: EventMsg) => void;
|
|
21
|
+
readonly stop: () => void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface Subscriber {
|
|
25
|
+
readonly queue: OutboundQueue;
|
|
26
|
+
readonly end: () => void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Starts the read-only events listener. A subscriber connects with no
|
|
31
|
+
* handshake and receives NDJSON wire events: first the snapshot, then every
|
|
32
|
+
* broadcast behind it. Input on the socket is ignored. A subscriber whose
|
|
33
|
+
* outbound queue overflows is disconnected rather than stalled — on
|
|
34
|
+
* reconnect it gets a fresh snapshot instead of the backlog it missed.
|
|
35
|
+
*/
|
|
36
|
+
export function startEventsServer(opts: EventsServerOptions): EventsServer {
|
|
37
|
+
const subscribers = new Set<Subscriber>();
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
unlinkSync(opts.socketPath);
|
|
41
|
+
} catch {}
|
|
42
|
+
|
|
43
|
+
const server = Bun.listen<Subscriber>({
|
|
44
|
+
unix: opts.socketPath,
|
|
45
|
+
socket: {
|
|
46
|
+
open(socket) {
|
|
47
|
+
const subscriber: Subscriber = {
|
|
48
|
+
queue: new OutboundQueue(socket, opts.queueBytes),
|
|
49
|
+
end: () => {
|
|
50
|
+
socket.end();
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
socket.data = subscriber;
|
|
55
|
+
|
|
56
|
+
subscribers.add(subscriber);
|
|
57
|
+
|
|
58
|
+
for (const event of opts.collectSnapshot()) {
|
|
59
|
+
if (!subscriber.queue.send(encodeMessage(event))) {
|
|
60
|
+
subscriber.end();
|
|
61
|
+
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
data() {},
|
|
67
|
+
drain(socket) {
|
|
68
|
+
socket.data.queue.drain();
|
|
69
|
+
},
|
|
70
|
+
close(socket) {
|
|
71
|
+
subscribers.delete(socket.data);
|
|
72
|
+
},
|
|
73
|
+
error() {},
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
broadcast(event) {
|
|
79
|
+
const line = encodeMessage(event);
|
|
80
|
+
|
|
81
|
+
for (const subscriber of subscribers) {
|
|
82
|
+
if (!subscriber.queue.send(line)) {
|
|
83
|
+
subscriber.end();
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
stop() {
|
|
88
|
+
server.stop(true);
|
|
89
|
+
|
|
90
|
+
for (const subscriber of subscribers) {
|
|
91
|
+
subscriber.end();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
subscribers.clear();
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
}
|
package/src/events.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { eventsSocketPath } from './shared/config';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Streams the daemon's events socket to stdout, one NDJSON wire event per
|
|
5
|
+
* line, until the daemon closes the connection. When no daemon is
|
|
6
|
+
* listening, prints a hint to stderr and exits nonzero instead of booting
|
|
7
|
+
* one.
|
|
8
|
+
*/
|
|
9
|
+
export async function runEvents(): Promise<void> {
|
|
10
|
+
const closed = Promise.withResolvers<void>();
|
|
11
|
+
|
|
12
|
+
try {
|
|
13
|
+
await Bun.connect({
|
|
14
|
+
unix: eventsSocketPath,
|
|
15
|
+
socket: {
|
|
16
|
+
data(_socket, buf) {
|
|
17
|
+
process.stdout.write(buf);
|
|
18
|
+
},
|
|
19
|
+
close() {
|
|
20
|
+
closed.resolve();
|
|
21
|
+
},
|
|
22
|
+
error() {
|
|
23
|
+
closed.resolve();
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
});
|
|
27
|
+
} catch {
|
|
28
|
+
console.error(`atc events: no daemon at ${eventsSocketPath} — start atc first`);
|
|
29
|
+
process.exit(1);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
await closed.promise;
|
|
33
|
+
}
|
package/src/mcp-server.ts
CHANGED
|
@@ -74,6 +74,12 @@ const TOOLS: readonly MCPTool[] = [
|
|
|
74
74
|
additionalProperties: false,
|
|
75
75
|
},
|
|
76
76
|
},
|
|
77
|
+
{
|
|
78
|
+
name: 'atc_session_screen',
|
|
79
|
+
description:
|
|
80
|
+
'Read the current terminal screen of a session as plain text, without attaching to it. Use it to see what a session printed or what it is waiting on before answering it with atc_session_input. A killed session keeps its last screen.',
|
|
81
|
+
inputSchema: SESSION_INPUT,
|
|
82
|
+
},
|
|
77
83
|
{
|
|
78
84
|
name: 'atc_session_update',
|
|
79
85
|
description:
|
|
@@ -262,6 +268,11 @@ async function runTool(
|
|
|
262
268
|
|
|
263
269
|
return 'sent';
|
|
264
270
|
}
|
|
271
|
+
case 'atc_session_screen': {
|
|
272
|
+
const ok = await client.sendRequest('session.screen', { session: args['session'] });
|
|
273
|
+
|
|
274
|
+
return typeof ok['text'] === 'string' ? ok['text'] : JSON.stringify(ok);
|
|
275
|
+
}
|
|
265
276
|
case 'atc_session_update': {
|
|
266
277
|
await client.sendRequest('session.update', {
|
|
267
278
|
session: args['session'],
|
package/src/protocol/protocol.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { isRecord } from '../shared/report';
|
|
|
5
5
|
* per line. Three message kinds, distinguished by which fields are present —
|
|
6
6
|
* request (id + m), response (id + ok or err), event (ev).
|
|
7
7
|
*/
|
|
8
|
-
export const PROTOCOL_V =
|
|
8
|
+
export const PROTOCOL_V = 3;
|
|
9
9
|
|
|
10
10
|
// Control lines are capped before buffering; PTY output is split into chunks
|
|
11
11
|
// so a queued response is delayed by at most one chunk.
|
|
@@ -64,6 +64,7 @@ export const REQUEST_PARAM_SCHEMAS = {
|
|
|
64
64
|
message: 'session.resize requires positive cols and rows',
|
|
65
65
|
}),
|
|
66
66
|
'session.resumeCommand': SESSION_DEFAULTED,
|
|
67
|
+
'session.screen': SESSION_DEFAULTED,
|
|
67
68
|
'session.eject': SESSION_DEFAULTED.extend({
|
|
68
69
|
prompt: buildDefaultedNonEmptyString(EJECT_DEFAULT_PROMPT),
|
|
69
70
|
}),
|