@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 +15 -4
- package/package.json +5 -1
- package/src/daemon/daemon-connection.ts +25 -0
- package/src/daemon/daemon.ts +12 -0
- package/src/daemon/screen-model.ts +42 -11
- package/src/mcp-server.ts +11 -0
- package/src/protocol/request-param-schemas.ts +1 -0
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
|
-
|
|
106
|
-
|
|
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.
|
|
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
|
|
package/src/daemon/daemon.ts
CHANGED
|
@@ -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
|
-
//
|
|
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
|
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
|
}),
|