@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.
- package/LICENSE +21 -0
- package/README.md +85 -0
- package/bin/atc +2 -0
- package/package.json +58 -0
- package/src/agent-adapter.ts +60 -0
- package/src/attach-registry.ts +89 -0
- package/src/boot-daemon.ts +106 -0
- package/src/claude-adapter.ts +235 -0
- package/src/cli.ts +101 -0
- package/src/collect-clean-env.ts +21 -0
- package/src/config.ts +57 -0
- package/src/daemon-client.ts +131 -0
- package/src/daemon-connection.ts +511 -0
- package/src/daemon-error.ts +16 -0
- package/src/daemon.ts +648 -0
- package/src/dirs.ts +67 -0
- package/src/get-build.ts +32 -0
- package/src/hook-report.ts +31 -0
- package/src/hooks.ts +54 -0
- package/src/index.ts +1076 -0
- package/src/mcp-server.ts +272 -0
- package/src/outbound-queue.ts +85 -0
- package/src/permission-registry.ts +104 -0
- package/src/protocol.ts +129 -0
- package/src/report.ts +33 -0
- package/src/screen-model.ts +48 -0
- package/src/sessions.ts +568 -0
- package/src/start-headless-run.ts +160 -0
- package/src/state-store.ts +131 -0
- package/src/statusline.ts +103 -0
- package/src/ui.ts +341 -0
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import { bootDaemonClient } from './boot-daemon';
|
|
2
|
+
import { DaemonError } from './daemon-error';
|
|
3
|
+
import { isRecord } from './report';
|
|
4
|
+
|
|
5
|
+
// The slice of the daemon client the tool handlers need.
|
|
6
|
+
interface FleetCaller {
|
|
7
|
+
readonly sendRequest: (
|
|
8
|
+
m: string,
|
|
9
|
+
p?: Readonly<Record<string, unknown>>,
|
|
10
|
+
) => Promise<Readonly<Record<string, unknown>>>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const PROTOCOL_FALLBACK = '2025-06-18';
|
|
14
|
+
|
|
15
|
+
interface MCPTool {
|
|
16
|
+
readonly name: string;
|
|
17
|
+
readonly description: string;
|
|
18
|
+
readonly inputSchema: Readonly<Record<string, unknown>>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const NO_INPUT: Readonly<Record<string, unknown>> = {
|
|
22
|
+
type: 'object',
|
|
23
|
+
properties: {},
|
|
24
|
+
additionalProperties: false,
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const SESSION_INPUT: Readonly<Record<string, unknown>> = {
|
|
28
|
+
type: 'object',
|
|
29
|
+
properties: {
|
|
30
|
+
session: { type: 'string', description: 'The atc session id, from atc_session_list' },
|
|
31
|
+
},
|
|
32
|
+
required: ['session'],
|
|
33
|
+
additionalProperties: false,
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const TOOLS: readonly MCPTool[] = [
|
|
37
|
+
{
|
|
38
|
+
name: 'atc_session_list',
|
|
39
|
+
description:
|
|
40
|
+
'List every session the atc daemon hosts: id, name, working directory, state (running, needs_you, done, exited), unread flag, and last activity.',
|
|
41
|
+
inputSchema: NO_INPUT,
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
name: 'atc_session_spawn',
|
|
45
|
+
description:
|
|
46
|
+
'Spawn a new Claude Code session in a directory. Returns the new session descriptor. Give it a prompt to start it working immediately.',
|
|
47
|
+
inputSchema: {
|
|
48
|
+
type: 'object',
|
|
49
|
+
properties: {
|
|
50
|
+
cwd: { type: 'string', description: 'Absolute path of the working directory' },
|
|
51
|
+
name: { type: 'string', description: 'Session name; defaults to the directory basename' },
|
|
52
|
+
prompt: { type: 'string', description: 'First message for the session' },
|
|
53
|
+
},
|
|
54
|
+
required: ['cwd'],
|
|
55
|
+
additionalProperties: false,
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
name: 'atc_session_input',
|
|
60
|
+
description:
|
|
61
|
+
'Type a line of text into a running session, as if the operator typed it and pressed enter. Use it to answer a session that is waiting on input.',
|
|
62
|
+
inputSchema: {
|
|
63
|
+
type: 'object',
|
|
64
|
+
properties: {
|
|
65
|
+
session: { type: 'string', description: 'The atc session id' },
|
|
66
|
+
text: { type: 'string', description: 'The line to type; a newline is appended' },
|
|
67
|
+
},
|
|
68
|
+
required: ['session', 'text'],
|
|
69
|
+
additionalProperties: false,
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
name: 'atc_session_kill',
|
|
74
|
+
description: 'Kill a session. A second kill on a dead session removes it from the list.',
|
|
75
|
+
inputSchema: SESSION_INPUT,
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
name: 'atc_session_ack',
|
|
79
|
+
description: 'Clear a session unread flag without attaching to it.',
|
|
80
|
+
inputSchema: SESSION_INPUT,
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
name: 'atc_resume_command',
|
|
84
|
+
description:
|
|
85
|
+
'Build the shell command that reopens a session outside atc (cd into its directory and claude --resume its id).',
|
|
86
|
+
inputSchema: SESSION_INPUT,
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
name: 'atc_dirs_list',
|
|
90
|
+
description: 'List directories sessions were previously spawned from, most recent first.',
|
|
91
|
+
inputSchema: NO_INPUT,
|
|
92
|
+
},
|
|
93
|
+
];
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* An MCP server over stdio bridging to the atc daemon: any MCP client —
|
|
97
|
+
* including a wrangled session — can list, spawn, and drive the fleet.
|
|
98
|
+
* stdout carries only JSON-RPC lines; the daemon is booted on demand.
|
|
99
|
+
*/
|
|
100
|
+
export async function runMCPServer(build: string): Promise<void> {
|
|
101
|
+
const client = await bootDaemonClient();
|
|
102
|
+
|
|
103
|
+
const decoder = new TextDecoder('utf-8');
|
|
104
|
+
|
|
105
|
+
let buffer = '';
|
|
106
|
+
|
|
107
|
+
for await (const chunk of Bun.stdin.stream()) {
|
|
108
|
+
buffer += decoder.decode(chunk, { stream: true });
|
|
109
|
+
|
|
110
|
+
const lines = buffer.split('\n');
|
|
111
|
+
|
|
112
|
+
buffer = lines.pop() ?? '';
|
|
113
|
+
|
|
114
|
+
for (const line of lines) {
|
|
115
|
+
if (line.trim() === '') {
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
await applyRPCLine(client, build, line);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
client.stop();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function applyRPCLine(client: FleetCaller, build: string, line: string): Promise<void> {
|
|
127
|
+
let parsed: unknown;
|
|
128
|
+
|
|
129
|
+
try {
|
|
130
|
+
parsed = JSON.parse(line);
|
|
131
|
+
} catch {
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (!isRecord(parsed) || typeof parsed['method'] !== 'string') {
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const method = parsed['method'];
|
|
140
|
+
const id = parsed['id'];
|
|
141
|
+
const params = isRecord(parsed['params']) ? parsed['params'] : {};
|
|
142
|
+
|
|
143
|
+
// Notifications carry no id and get no response.
|
|
144
|
+
if (id === undefined || id === null) {
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (typeof id !== 'string' && typeof id !== 'number') {
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
switch (method) {
|
|
153
|
+
case 'initialize': {
|
|
154
|
+
const requested = params['protocolVersion'];
|
|
155
|
+
|
|
156
|
+
sendResult(id, {
|
|
157
|
+
protocolVersion: typeof requested === 'string' ? requested : PROTOCOL_FALLBACK,
|
|
158
|
+
capabilities: { tools: {} },
|
|
159
|
+
serverInfo: { name: 'atc', version: build },
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
case 'ping': {
|
|
165
|
+
sendResult(id, {});
|
|
166
|
+
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
case 'tools/list': {
|
|
170
|
+
sendResult(id, { tools: TOOLS });
|
|
171
|
+
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
case 'tools/call': {
|
|
175
|
+
await applyToolCall(client, id, params);
|
|
176
|
+
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
default: {
|
|
180
|
+
sendError(id, -32_601, `unknown method '${method}'`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function applyToolCall(
|
|
186
|
+
client: FleetCaller,
|
|
187
|
+
id: string | number,
|
|
188
|
+
params: Readonly<Record<string, unknown>>,
|
|
189
|
+
): Promise<void> {
|
|
190
|
+
const name = typeof params['name'] === 'string' ? params['name'] : '';
|
|
191
|
+
const args = isRecord(params['arguments']) ? params['arguments'] : {};
|
|
192
|
+
|
|
193
|
+
try {
|
|
194
|
+
const text = await runTool(client, name, args);
|
|
195
|
+
|
|
196
|
+
sendResult(id, { content: [{ type: 'text', text }] });
|
|
197
|
+
} catch (error) {
|
|
198
|
+
let msg = String(error);
|
|
199
|
+
|
|
200
|
+
if (error instanceof DaemonError) {
|
|
201
|
+
msg = `${error.code}: ${error.message}`;
|
|
202
|
+
} else if (error instanceof Error) {
|
|
203
|
+
msg = error.message;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
sendResult(id, { content: [{ type: 'text', text: msg }], isError: true });
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async function runTool(
|
|
211
|
+
client: FleetCaller,
|
|
212
|
+
name: string,
|
|
213
|
+
args: Readonly<Record<string, unknown>>,
|
|
214
|
+
): Promise<string> {
|
|
215
|
+
switch (name) {
|
|
216
|
+
case 'atc_session_list': {
|
|
217
|
+
const ok = await client.sendRequest('session.list');
|
|
218
|
+
|
|
219
|
+
return JSON.stringify(ok['sessions'], null, 2);
|
|
220
|
+
}
|
|
221
|
+
case 'atc_session_spawn': {
|
|
222
|
+
const ok = await client.sendRequest('session.spawn', {
|
|
223
|
+
cwd: args['cwd'],
|
|
224
|
+
...(typeof args['name'] === 'string' ? { name: args['name'] } : {}),
|
|
225
|
+
...(typeof args['prompt'] === 'string' ? { prompt: args['prompt'] } : {}),
|
|
226
|
+
cols: 100,
|
|
227
|
+
rows: 30,
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
return JSON.stringify(ok['session'], null, 2);
|
|
231
|
+
}
|
|
232
|
+
case 'atc_session_input': {
|
|
233
|
+
await client.sendRequest('session.input', {
|
|
234
|
+
session: args['session'],
|
|
235
|
+
d: `${typeof args['text'] === 'string' ? args['text'] : ''}\n`,
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
return 'sent';
|
|
239
|
+
}
|
|
240
|
+
case 'atc_session_kill': {
|
|
241
|
+
await client.sendRequest('session.kill', { session: args['session'] });
|
|
242
|
+
|
|
243
|
+
return 'killed';
|
|
244
|
+
}
|
|
245
|
+
case 'atc_session_ack': {
|
|
246
|
+
await client.sendRequest('session.ack', { session: args['session'] });
|
|
247
|
+
|
|
248
|
+
return 'acked';
|
|
249
|
+
}
|
|
250
|
+
case 'atc_resume_command': {
|
|
251
|
+
const ok = await client.sendRequest('session.resumeCommand', { session: args['session'] });
|
|
252
|
+
|
|
253
|
+
return typeof ok['command'] === 'string' ? ok['command'] : JSON.stringify(ok);
|
|
254
|
+
}
|
|
255
|
+
case 'atc_dirs_list': {
|
|
256
|
+
const ok = await client.sendRequest('dirs.list');
|
|
257
|
+
|
|
258
|
+
return JSON.stringify(ok['dirs'], null, 2);
|
|
259
|
+
}
|
|
260
|
+
default: {
|
|
261
|
+
throw new Error(`unknown tool '${name}'`);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function sendResult(id: string | number, result: Readonly<Record<string, unknown>>): void {
|
|
267
|
+
process.stdout.write(`${JSON.stringify({ jsonrpc: '2.0', id, result })}\n`);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function sendError(id: string | number, code: number, message: string): void {
|
|
271
|
+
process.stdout.write(`${JSON.stringify({ jsonrpc: '2.0', id, error: { code, message } })}\n`);
|
|
272
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// The slice of a Bun socket the queue needs; `write` returns bytes accepted.
|
|
2
|
+
export interface SocketWriter {
|
|
3
|
+
// oxlint-disable-next-line prefer-readonly-parameter-types -- a readonly view cannot satisfy Bun's BufferSource union; the queue never mutates chunks
|
|
4
|
+
readonly write: (data: Uint8Array) => number;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
const encoder = new TextEncoder();
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Bounded outbound buffer for one socket. `socket.write()` returns bytes
|
|
11
|
+
* accepted and silently drops the rest, so every short write parks the
|
|
12
|
+
* remainder here and the socket's drain callback flushes it. A payload is
|
|
13
|
+
* never cut mid-line: once its first byte is on the wire, the rest is
|
|
14
|
+
* queued even past capacity, and only whole not-yet-started payloads are
|
|
15
|
+
* refused on overflow.
|
|
16
|
+
*/
|
|
17
|
+
export class OutboundQueue {
|
|
18
|
+
private readonly socket: SocketWriter;
|
|
19
|
+
|
|
20
|
+
private readonly capacity: number;
|
|
21
|
+
|
|
22
|
+
private chunks: Uint8Array[] = [];
|
|
23
|
+
|
|
24
|
+
private queued = 0;
|
|
25
|
+
|
|
26
|
+
constructor(socket: SocketWriter, capacity = 2 * 1024 * 1024) {
|
|
27
|
+
this.socket = socket;
|
|
28
|
+
this.capacity = capacity;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
get queuedBytes(): number {
|
|
32
|
+
return this.queued;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// false: the payload was refused whole because the queue is over capacity.
|
|
36
|
+
send(payload: string): boolean {
|
|
37
|
+
const bytes = encoder.encode(payload);
|
|
38
|
+
|
|
39
|
+
if (this.chunks.length > 0) {
|
|
40
|
+
if (this.queued + bytes.length > this.capacity) {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
this.chunks.push(bytes);
|
|
45
|
+
|
|
46
|
+
this.queued += bytes.length;
|
|
47
|
+
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const wrote = Math.max(this.socket.write(bytes), 0);
|
|
52
|
+
|
|
53
|
+
if (wrote < bytes.length) {
|
|
54
|
+
const rest = bytes.subarray(wrote);
|
|
55
|
+
|
|
56
|
+
this.chunks.push(rest);
|
|
57
|
+
|
|
58
|
+
this.queued += rest.length;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
drain(): void {
|
|
65
|
+
while (this.chunks.length > 0) {
|
|
66
|
+
const [head] = this.chunks;
|
|
67
|
+
|
|
68
|
+
if (head === undefined) {
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const wrote = Math.max(this.socket.write(head), 0);
|
|
73
|
+
|
|
74
|
+
this.queued -= wrote;
|
|
75
|
+
|
|
76
|
+
if (wrote < head.length) {
|
|
77
|
+
this.chunks[0] = head.subarray(wrote);
|
|
78
|
+
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
this.chunks.shift();
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
export interface PermissionRequest {
|
|
2
|
+
readonly id: string;
|
|
3
|
+
readonly sessionID: string;
|
|
4
|
+
readonly message: string;
|
|
5
|
+
readonly respondable: boolean;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export type AnswerResult = 'ok' | 'already_answered' | 'unsupported' | 'unknown';
|
|
9
|
+
|
|
10
|
+
interface PendingRequest {
|
|
11
|
+
readonly req: PermissionRequest;
|
|
12
|
+
readonly timer: ReturnType<typeof setTimeout>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Pending permission requests with first-response-wins arbitration. The
|
|
17
|
+
* first answer resolves a request and every later answer reports
|
|
18
|
+
* already_answered; an unanswered request times out to deny; requests that
|
|
19
|
+
* stop mattering (the session was answered directly or died) resolve as
|
|
20
|
+
* dismissed. Resolution always reaches the callbacks exactly once.
|
|
21
|
+
*/
|
|
22
|
+
export class PermissionRegistry {
|
|
23
|
+
onRequested: (req: PermissionRequest) => void = () => {};
|
|
24
|
+
|
|
25
|
+
onResolved: (id: string, decision: string) => void = () => {};
|
|
26
|
+
|
|
27
|
+
private readonly timeoutMs: number;
|
|
28
|
+
|
|
29
|
+
private readonly pending = new Map<string, PendingRequest>();
|
|
30
|
+
|
|
31
|
+
private readonly resolved = new Set<string>();
|
|
32
|
+
|
|
33
|
+
private counter = 0;
|
|
34
|
+
|
|
35
|
+
constructor(timeoutMs = 60_000) {
|
|
36
|
+
this.timeoutMs = timeoutMs;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
open(sessionID: string, message: string, respondable: boolean): PermissionRequest {
|
|
40
|
+
const id = `p${++this.counter}`;
|
|
41
|
+
const req: PermissionRequest = { id, sessionID, message, respondable };
|
|
42
|
+
|
|
43
|
+
const timer = setTimeout(() => {
|
|
44
|
+
this.removeRequest(id, 'deny');
|
|
45
|
+
}, this.timeoutMs);
|
|
46
|
+
|
|
47
|
+
this.pending.set(id, { req, timer });
|
|
48
|
+
this.onRequested(req);
|
|
49
|
+
|
|
50
|
+
return req;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
answer(id: string, decision: string): AnswerResult {
|
|
54
|
+
if (this.resolved.has(id)) {
|
|
55
|
+
return 'already_answered';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const entry = this.pending.get(id);
|
|
59
|
+
|
|
60
|
+
if (entry === undefined) {
|
|
61
|
+
return 'unknown';
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (!entry.req.respondable) {
|
|
65
|
+
return 'unsupported';
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
this.removeRequest(id, decision);
|
|
69
|
+
|
|
70
|
+
return 'ok';
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
answerAll(sessionID: string, decision: string): void {
|
|
74
|
+
for (const [id, entry] of this.pending) {
|
|
75
|
+
if (entry.req.sessionID === sessionID) {
|
|
76
|
+
this.removeRequest(id, decision);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
private removeRequest(id: string, decision: string): void {
|
|
82
|
+
const entry = this.pending.get(id);
|
|
83
|
+
|
|
84
|
+
if (entry === undefined) {
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
clearTimeout(entry.timer);
|
|
89
|
+
|
|
90
|
+
this.pending.delete(id);
|
|
91
|
+
this.resolved.add(id);
|
|
92
|
+
|
|
93
|
+
// The resolved set only exists to tell already_answered from unknown;
|
|
94
|
+
// cap it so a long-lived daemon cannot grow it without bound.
|
|
95
|
+
if (this.resolved.size > 1000) {
|
|
96
|
+
for (const oldest of this.resolved) {
|
|
97
|
+
this.resolved.delete(oldest);
|
|
98
|
+
break;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
this.onResolved(id, decision);
|
|
103
|
+
}
|
|
104
|
+
}
|
package/src/protocol.ts
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { isRecord } from './report';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The daemon/client wire dialect: NDJSON over a unix socket, one JSON object
|
|
5
|
+
* per line. Three message kinds, distinguished by which fields are present —
|
|
6
|
+
* request (id + m), response (id + ok or err), event (ev).
|
|
7
|
+
*/
|
|
8
|
+
export const PROTOCOL_V = 1;
|
|
9
|
+
|
|
10
|
+
// Control lines are capped before buffering; PTY output is split into chunks
|
|
11
|
+
// so a queued response is delayed by at most one chunk.
|
|
12
|
+
export const MAX_LINE = 1_048_576;
|
|
13
|
+
export const MAX_CHUNK = 65_536;
|
|
14
|
+
|
|
15
|
+
const ERROR_CODES = [
|
|
16
|
+
'protocol_mismatch',
|
|
17
|
+
'unauthorized',
|
|
18
|
+
'unknown_method',
|
|
19
|
+
'bad_args',
|
|
20
|
+
'no_such_session',
|
|
21
|
+
'session_dead',
|
|
22
|
+
'unsupported',
|
|
23
|
+
'already_answered',
|
|
24
|
+
'too_slow',
|
|
25
|
+
'internal',
|
|
26
|
+
] as const;
|
|
27
|
+
|
|
28
|
+
export type ErrorCode = (typeof ERROR_CODES)[number];
|
|
29
|
+
|
|
30
|
+
interface ProtocolError {
|
|
31
|
+
readonly code: ErrorCode;
|
|
32
|
+
readonly msg: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface RequestMsg {
|
|
36
|
+
readonly v: number;
|
|
37
|
+
readonly id: number;
|
|
38
|
+
readonly m: string;
|
|
39
|
+
readonly p?: Readonly<Record<string, unknown>>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface ResponseMsg {
|
|
43
|
+
readonly v: number;
|
|
44
|
+
readonly id: number;
|
|
45
|
+
readonly ok?: Readonly<Record<string, unknown>>;
|
|
46
|
+
readonly err?: ProtocolError;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface EventMsg {
|
|
50
|
+
readonly v: number;
|
|
51
|
+
readonly ev: string;
|
|
52
|
+
readonly [field: string]: unknown;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export type DecodedMsg =
|
|
56
|
+
| { readonly kind: 'request'; readonly msg: RequestMsg }
|
|
57
|
+
| { readonly kind: 'response'; readonly msg: ResponseMsg }
|
|
58
|
+
| { readonly kind: 'event'; readonly msg: EventMsg }
|
|
59
|
+
| { readonly kind: 'malformed'; readonly reason: string };
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Classifies one NDJSON line. Unknown fields pass through untouched so
|
|
63
|
+
* additive evolution never breaks a peer; a line that parses but fits no
|
|
64
|
+
* message kind is malformed, and the caller closes the connection.
|
|
65
|
+
*/
|
|
66
|
+
export function decodeMessage(line: string): DecodedMsg {
|
|
67
|
+
let parsed: unknown;
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
parsed = JSON.parse(line);
|
|
71
|
+
} catch {
|
|
72
|
+
return { kind: 'malformed', reason: 'not valid JSON' };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (!isRecord(parsed) || typeof parsed['v'] !== 'number') {
|
|
76
|
+
return { kind: 'malformed', reason: 'missing v' };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (typeof parsed['ev'] === 'string') {
|
|
80
|
+
return { kind: 'event', msg: { ...parsed, v: parsed['v'], ev: parsed['ev'] } };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (typeof parsed['id'] !== 'number') {
|
|
84
|
+
return { kind: 'malformed', reason: 'missing id' };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (typeof parsed['m'] === 'string') {
|
|
88
|
+
const p = parsed['p'];
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
kind: 'request',
|
|
92
|
+
msg: {
|
|
93
|
+
v: parsed['v'],
|
|
94
|
+
id: parsed['id'],
|
|
95
|
+
m: parsed['m'],
|
|
96
|
+
...(isRecord(p) ? { p } : {}),
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const ok = parsed['ok'];
|
|
102
|
+
const err = parsed['err'];
|
|
103
|
+
|
|
104
|
+
if (isRecord(ok) || isProtocolError(err)) {
|
|
105
|
+
return {
|
|
106
|
+
kind: 'response',
|
|
107
|
+
msg: {
|
|
108
|
+
v: parsed['v'],
|
|
109
|
+
id: parsed['id'],
|
|
110
|
+
...(isRecord(ok) ? { ok } : {}),
|
|
111
|
+
...(isProtocolError(err) ? { err } : {}),
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return { kind: 'malformed', reason: 'no m, ok, or err' };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function encodeMessage(msg: EventMsg | RequestMsg | ResponseMsg): string {
|
|
120
|
+
return `${JSON.stringify(msg)}\n`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function isProtocolError(value: unknown): value is ProtocolError {
|
|
124
|
+
return (
|
|
125
|
+
isRecord(value) &&
|
|
126
|
+
typeof value['msg'] === 'string' &&
|
|
127
|
+
ERROR_CODES.some((code) => code === value['code'])
|
|
128
|
+
);
|
|
129
|
+
}
|
package/src/report.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Fire-and-forget delivery of one JSON line to the atc unix socket, used by
|
|
2
|
+
// the hook and statusline commands running inside wrangled sessions. Never
|
|
3
|
+
// throws: a dead socket must not break the session doing the reporting.
|
|
4
|
+
async function sendLine(sock: string, line: string): Promise<void> {
|
|
5
|
+
const resolvers = Promise.withResolvers<void>();
|
|
6
|
+
|
|
7
|
+
await Bun.connect({
|
|
8
|
+
unix: sock,
|
|
9
|
+
socket: {
|
|
10
|
+
open(s) {
|
|
11
|
+
s.write(line);
|
|
12
|
+
s.end();
|
|
13
|
+
},
|
|
14
|
+
close() {
|
|
15
|
+
resolvers.resolve();
|
|
16
|
+
},
|
|
17
|
+
data() {},
|
|
18
|
+
error() {},
|
|
19
|
+
},
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
await resolvers.promise;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function sendReport(sock: string, line: string, timeoutMs: number): Promise<void> {
|
|
26
|
+
try {
|
|
27
|
+
await Promise.race([sendLine(sock, line), Bun.sleep(timeoutMs)]);
|
|
28
|
+
} catch {}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function isRecord(value: unknown): value is Record<string, unknown> {
|
|
32
|
+
return typeof value === 'object' && value !== null;
|
|
33
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { SerializeAddon } from '@xterm/addon-serialize';
|
|
2
|
+
import { Terminal } from '@xterm/headless';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A per-session vt state machine: consumes every PTY byte continuously and
|
|
6
|
+
* renders the current screen as an ANSI replay string, so attaching a
|
|
7
|
+
* client is an instant repaint instead of a resize jiggle. Scrollback is
|
|
8
|
+
* capped aggressively — the model exists for the current screen, not
|
|
9
|
+
* history; transcripts on disk are the durable copy.
|
|
10
|
+
*/
|
|
11
|
+
export class ScreenModel {
|
|
12
|
+
private readonly term: Terminal;
|
|
13
|
+
|
|
14
|
+
private readonly serializer: SerializeAddon;
|
|
15
|
+
|
|
16
|
+
private flushed: Promise<void> = Promise.resolve();
|
|
17
|
+
|
|
18
|
+
constructor(cols: number, rows: number) {
|
|
19
|
+
this.term = new Terminal({ cols, rows, allowProposedApi: true, scrollback: 300 });
|
|
20
|
+
this.serializer = new SerializeAddon();
|
|
21
|
+
|
|
22
|
+
this.term.loadAddon(this.serializer);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
record(data: string): void {
|
|
26
|
+
this.flushed = new Promise((resolve) => {
|
|
27
|
+
this.term.write(data, () => {
|
|
28
|
+
resolve();
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// The terminal parses asynchronously; the replay waits for every recorded
|
|
34
|
+
// byte to land in the buffer before serializing.
|
|
35
|
+
async renderReplay(): Promise<string> {
|
|
36
|
+
await this.flushed;
|
|
37
|
+
|
|
38
|
+
return this.serializer.serialize();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
updateDims(cols: number, rows: number): void {
|
|
42
|
+
this.term.resize(cols, rows);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
stop(): void {
|
|
46
|
+
this.term.dispose();
|
|
47
|
+
}
|
|
48
|
+
}
|