@zgeoff/atc 2.0.0 → 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 CHANGED
@@ -97,13 +97,24 @@ reporting works.
97
97
  is taken on your machine.
98
98
  - [Gateways](./docs/guides/configuration.md#gateways) — run the Claude CLI against Claude-compatible
99
99
  backends (GLM and friends), each as its own agent in one fleet.
100
- - [Daemon hooks](./docs/guides/configuration.md#daemon-hooks) — run your own commands on fleet
101
- events, or pipe the full NDJSON event stream from `atc events`.
102
100
  - [Attention hooks](./docs/guides/configuration.md#attention-hooks-grok-and-codex) — the Grok and
103
101
  Codex self-install in detail.
104
102
 
105
- `atc mcp` exposes the fleet as MCP tools (list, spawn, drive, organise) to any MCP client, wrangled
106
- sessions included:
103
+ ## Integrations
104
+
105
+ Everything the fleet does broadcasts as a wire event — sessions added, state changes, attaches,
106
+ renames, permission requests. The [events guide](./docs/guides/events.md) covers the three ways to
107
+ consume the stream:
108
+
109
+ - [Daemon hooks](./docs/guides/events.md#daemon-hooks) — run your own commands on fleet events,
110
+ straight from `config.json`.
111
+ - [`atc events`](./docs/guides/events.md#atc-events) — the stream on stdout, one NDJSON line per
112
+ event; pipe it into `jq` or your own tooling.
113
+ - [The events socket](./docs/guides/events.md#the-events-socket) — a read-only unix socket any
114
+ program can subscribe to, stable across atc upgrades.
115
+
116
+ `atc mcp` exposes the fleet as MCP tools (list, spawn, drive, read the screen, organise) to any MCP
117
+ client, wrangled sessions included:
107
118
 
108
119
  ```sh
109
120
  claude mcp add --scope user atc -- atc mcp
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zgeoff/atc",
3
- "version": "2.0.0",
3
+ "version": "2.1.0",
4
4
  "description": "Terminal control tower for Claude Code sessions",
5
5
  "homepage": "https://github.com/zgeoff/atc#readme",
6
6
  "bugs": "https://github.com/zgeoff/atc/issues",
@@ -59,5 +59,9 @@
59
59
  "type-fest": "5.8.0",
60
60
  "typescript": "7.0.2"
61
61
  },
62
+ "overrides": {
63
+ "fast-uri": "3.1.6",
64
+ "qs": "6.16.0"
65
+ },
62
66
  "packageManager": "bun@1.3.10"
63
67
  }
@@ -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: (
@@ -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
 
@@ -525,6 +525,18 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
525
525
  return true;
526
526
  },
527
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
+ },
528
540
  answerPermission: (request, decision) => registry.answer(request, decision),
529
541
  attachSession: (client, sessionID, dims) => {
530
542
  const s = mgr.sessions.find((x) => x.id === sessionID);
@@ -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
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'],
@@ -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
  }),