@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.
@@ -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: 'session.desync', s: sessionID, dropped });
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
 
@@ -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 emitEvent = (event: EventMsg) => {
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
- v: PROTOCOL_V,
100
- ev: 'permission.requested',
101
- request: req.id,
102
- s: req.sessionID,
103
- message: req.message,
104
- respondable: req.respondable,
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: 'permission.resolved', request: id, decision });
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: 'session.output', s: sessionID, seq, d: chunk },
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
- v: PROTOCOL_V,
195
- ev: 'session.resized',
196
- s: sessionID,
197
- cols: dims.cols,
198
- rows: dims.rows,
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: 'session.output', s: s.id, seq, d: chunk };
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
- emitEvent(event);
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
- // The terminal parses asynchronously, and bytes recorded while a flush is
88
- // awaited re-arm it — so the replay drains until no newer write is
89
- // pending. Serializing earlier would omit bytes already streamed live to
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
- let pending: Promise<void>;
97
+ await this.waitForFlush();
94
98
 
95
- do {
96
- pending = this.flushed;
99
+ return RESET_INPUT_MODES + this.renderVisibleScreen() + this.renderInputModes();
100
+ }
97
101
 
98
- await pending;
99
- } while (pending !== this.flushed);
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
- return RESET_INPUT_MODES + this.renderVisibleScreen() + this.renderInputModes();
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'],
@@ -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 = 2;
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
  }),