@zgeoff/atc 2.13.0 → 2.15.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 +2 -1
- package/package.json +1 -1
- package/src/client/boot-daemon.ts +47 -21
- package/src/client/daemon-client.ts +5 -22
- package/src/client/format-protocol-mismatch.ts +37 -0
- package/src/client/index.ts +54 -1
- package/src/daemon/build-imp-provider.ts +70 -23
- package/src/daemon/build-report-trail-entry.ts +4 -2
- package/src/daemon/build-report-view.ts +41 -0
- package/src/daemon/build-scoped-context.ts +5 -0
- package/src/daemon/daemon-connection.ts +42 -16
- package/src/daemon/daemon.ts +8 -1
- package/src/daemon/hooks.ts +5 -17
- package/src/daemon/imp-client-port.ts +83 -27
- package/src/daemon/imp-provider.ts +5 -11
- package/src/daemon/materialize-workspace.ts +5 -2
- package/src/daemon/read-imp-token.ts +30 -0
- package/src/daemon/sessions.ts +18 -3
- package/src/mcp/mcp-tools.ts +32 -1
- package/src/mcp/reconnecting-caller.ts +1 -0
- package/src/mcp/require-daemon-features.ts +1 -0
- package/src/mcp/run-tool.ts +5 -0
- package/src/mcp-server.ts +3 -14
- package/src/protocol/daemon-features.ts +3 -0
- package/src/protocol/line-decoder.ts +34 -0
- package/src/{shared → protocol}/open-bridge-socket.ts +4 -7
- package/src/protocol/request-param-schemas.ts +6 -0
- package/src/report.ts +1 -1
- package/src/run-bridge-tap.ts +2 -2
- package/src/statusline.ts +1 -1
- package/src/store/fleet-entry.ts +4 -1
- package/src/store/run-migrations.ts +9 -0
- package/src/store/state-store.ts +119 -15
- package/src/store/trail-entry.ts +3 -0
- /package/src/{shared → protocol}/send-bridge-request.ts +0 -0
package/src/daemon/hooks.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { unlinkSync } from 'node:fs';
|
|
2
|
+
import { LineDecoder } from '../protocol/line-decoder';
|
|
2
3
|
import { socketPath } from '../shared/config';
|
|
3
4
|
import type { SessionID } from '../shared/session-id';
|
|
4
5
|
import { parseHookLine } from './parse-hook-line';
|
|
@@ -9,12 +10,9 @@ export interface HookEvent {
|
|
|
9
10
|
payload: Record<string, unknown>;
|
|
10
11
|
}
|
|
11
12
|
|
|
12
|
-
// Per-connection read state: the
|
|
13
|
-
// and a streaming decoder so a multi-byte character split across two reads
|
|
14
|
-
// decodes whole.
|
|
13
|
+
// Per-connection read state: the connection's own line framing.
|
|
15
14
|
interface HookConnection {
|
|
16
|
-
|
|
17
|
-
readonly decoder: TextDecoder;
|
|
15
|
+
readonly lines: LineDecoder;
|
|
18
16
|
}
|
|
19
17
|
|
|
20
18
|
export function startHookServer(onEvent: (e: HookEvent) => void, path: string = socketPath) {
|
|
@@ -26,17 +24,7 @@ export function startHookServer(onEvent: (e: HookEvent) => void, path: string =
|
|
|
26
24
|
unix: path,
|
|
27
25
|
socket: {
|
|
28
26
|
data(socket, buf) {
|
|
29
|
-
const
|
|
30
|
-
const buffered = conn.pending + conn.decoder.decode(buf, { stream: true });
|
|
31
|
-
const lines = buffered.split('\n');
|
|
32
|
-
|
|
33
|
-
conn.pending = lines.pop() ?? '';
|
|
34
|
-
|
|
35
|
-
for (const line of lines) {
|
|
36
|
-
if (line.trim() === '') {
|
|
37
|
-
continue;
|
|
38
|
-
}
|
|
39
|
-
|
|
27
|
+
for (const line of socket.data.lines.splitChunk(buf)) {
|
|
40
28
|
const event = parseHookLine(line);
|
|
41
29
|
|
|
42
30
|
if (event !== null) {
|
|
@@ -45,7 +33,7 @@ export function startHookServer(onEvent: (e: HookEvent) => void, path: string =
|
|
|
45
33
|
}
|
|
46
34
|
},
|
|
47
35
|
open(socket) {
|
|
48
|
-
socket.data = {
|
|
36
|
+
socket.data = { lines: new LineDecoder() };
|
|
49
37
|
},
|
|
50
38
|
error() {},
|
|
51
39
|
},
|
|
@@ -18,12 +18,15 @@ import type {
|
|
|
18
18
|
import { ImpPortError } from './imp-port-error';
|
|
19
19
|
|
|
20
20
|
/**
|
|
21
|
-
* Where impd listens and the
|
|
22
|
-
*
|
|
21
|
+
* Where impd listens, and how the daemon reads the token it calls impd
|
|
22
|
+
* with: null for no token. The port reads the token again before each
|
|
23
|
+
* call and connection, so a token that changes at its source takes effect
|
|
24
|
+
* on the next one. A read that throws fails that call without reaching
|
|
25
|
+
* impd.
|
|
23
26
|
*/
|
|
24
27
|
export interface ImpClientPortOptions {
|
|
25
28
|
readonly url: string;
|
|
26
|
-
readonly
|
|
29
|
+
readonly readToken: () => string | null;
|
|
27
30
|
}
|
|
28
31
|
|
|
29
32
|
/**
|
|
@@ -35,30 +38,26 @@ export interface ImpClientPortOptions {
|
|
|
35
38
|
export class ImpClientPort implements ImpPort {
|
|
36
39
|
private readonly url: string;
|
|
37
40
|
|
|
38
|
-
private readonly
|
|
41
|
+
private readonly readToken: () => string | null;
|
|
39
42
|
|
|
40
|
-
|
|
43
|
+
// The client for the token read last, rebuilt when the token changes.
|
|
44
|
+
private client: Readonly<{ token: string | null; client: ImpClient }> | null = null;
|
|
41
45
|
|
|
42
46
|
constructor(options: ImpClientPortOptions) {
|
|
43
47
|
this.url = options.url;
|
|
44
|
-
this.
|
|
45
|
-
|
|
46
|
-
this.client = createImpClient({
|
|
47
|
-
url: options.url,
|
|
48
|
-
...(options.token === null ? {} : { token: options.token }),
|
|
49
|
-
});
|
|
48
|
+
this.readToken = options.readToken;
|
|
50
49
|
}
|
|
51
50
|
|
|
52
51
|
// An impd from before the flags has neither.
|
|
53
52
|
readonly readFeatures = async (): Promise<ImpFeatures> => {
|
|
54
|
-
const info = await this.tryCall(() =>
|
|
53
|
+
const info = await this.tryCall((client) => client.system.info());
|
|
55
54
|
|
|
56
55
|
return info.features ?? { sessionOffsets: false, leases: false };
|
|
57
56
|
};
|
|
58
57
|
|
|
59
58
|
readonly readImp = async (name: string): Promise<ImpView | null> => {
|
|
60
59
|
try {
|
|
61
|
-
const imp = await this.tryCall(() =>
|
|
60
|
+
const imp = await this.tryCall((client) => client.imps.get({ name }));
|
|
62
61
|
|
|
63
62
|
return {
|
|
64
63
|
name: imp.name,
|
|
@@ -76,8 +75,8 @@ export class ImpClientPort implements ImpPort {
|
|
|
76
75
|
};
|
|
77
76
|
|
|
78
77
|
readonly createImp = async (spec: ImpCreateSpec): Promise<ImpView> => {
|
|
79
|
-
const imp = await this.tryCall(() =>
|
|
80
|
-
|
|
78
|
+
const imp = await this.tryCall((client) =>
|
|
79
|
+
client.imps.create({
|
|
81
80
|
name: spec.name,
|
|
82
81
|
...(spec.image === undefined ? {} : { image: spec.image }),
|
|
83
82
|
...(spec.memoryMib === undefined ? {} : { memoryMib: spec.memoryMib }),
|
|
@@ -92,7 +91,9 @@ export class ImpClientPort implements ImpPort {
|
|
|
92
91
|
label: string,
|
|
93
92
|
ttlSeconds: number,
|
|
94
93
|
): Promise<ImpLease> => {
|
|
95
|
-
const lease = await this.tryCall(() =>
|
|
94
|
+
const lease = await this.tryCall((client) =>
|
|
95
|
+
client.leases.acquire({ name, label, ttlSeconds }),
|
|
96
|
+
);
|
|
96
97
|
|
|
97
98
|
return toLease(lease);
|
|
98
99
|
};
|
|
@@ -102,23 +103,23 @@ export class ImpClientPort implements ImpPort {
|
|
|
102
103
|
label: string,
|
|
103
104
|
ttlSeconds: number,
|
|
104
105
|
): Promise<ImpLease> => {
|
|
105
|
-
const lease = await this.tryCall(() =>
|
|
106
|
+
const lease = await this.tryCall((client) => client.leases.renew({ name, label, ttlSeconds }));
|
|
106
107
|
|
|
107
108
|
return toLease(lease);
|
|
108
109
|
};
|
|
109
110
|
|
|
110
111
|
readonly releaseLease = async (name: string, label: string): Promise<boolean> => {
|
|
111
|
-
const result = await this.tryCall(() =>
|
|
112
|
+
const result = await this.tryCall((client) => client.leases.release({ name, label }));
|
|
112
113
|
|
|
113
114
|
return result.released;
|
|
114
115
|
};
|
|
115
116
|
|
|
116
117
|
readonly suspendImp = async (name: string): Promise<void> => {
|
|
117
|
-
await this.tryCall(() =>
|
|
118
|
+
await this.tryCall((client) => client.imps.sleep({ name }));
|
|
118
119
|
};
|
|
119
120
|
|
|
120
121
|
readonly destroyImp = async (name: string): Promise<void> => {
|
|
121
|
-
await this.tryCall(() =>
|
|
122
|
+
await this.tryCall((client) => client.imps.destroy({ name }));
|
|
122
123
|
};
|
|
123
124
|
|
|
124
125
|
// The exec WebSocket answers only after it opens, so nothing reaches the
|
|
@@ -127,9 +128,23 @@ export class ImpClientPort implements ImpPort {
|
|
|
127
128
|
request: ImpSessionRequest,
|
|
128
129
|
handlers: ImpSessionHandlers,
|
|
129
130
|
): ImpSessionConnection => {
|
|
131
|
+
const token = this.tryReadToken();
|
|
132
|
+
|
|
133
|
+
// A token that cannot be read ends the connection the way impd's
|
|
134
|
+
// refusal of a token does, without dialing impd.
|
|
135
|
+
if (token instanceof ImpPortError) {
|
|
136
|
+
return {
|
|
137
|
+
outcome: Promise.resolve({ kind: 'unauthorized' }),
|
|
138
|
+
write: () => {},
|
|
139
|
+
resize: () => {},
|
|
140
|
+
sendSignal: () => {},
|
|
141
|
+
close: () => {},
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
130
145
|
const session = openExecSession({
|
|
131
146
|
baseUrl: this.url,
|
|
132
|
-
token
|
|
147
|
+
token,
|
|
133
148
|
start:
|
|
134
149
|
request.kind === 'start'
|
|
135
150
|
? {
|
|
@@ -179,8 +194,8 @@ export class ImpClientPort implements ImpPort {
|
|
|
179
194
|
|
|
180
195
|
// oxlint-disable-next-line prefer-readonly-parameter-types -- the command's input bytes have no readonly form
|
|
181
196
|
readonly runCommand = async (name: string, command: ImpCommand): Promise<ImpCommandResult> => {
|
|
182
|
-
const result = await this.tryCall(() =>
|
|
183
|
-
|
|
197
|
+
const result = await this.tryCall((client) =>
|
|
198
|
+
client.run(name, command.argv, {
|
|
184
199
|
...(command.cwd === undefined ? {} : { cwd: command.cwd }),
|
|
185
200
|
...(command.stdin === undefined ? {} : { stdin: command.stdin }),
|
|
186
201
|
}),
|
|
@@ -194,12 +209,30 @@ export class ImpClientPort implements ImpPort {
|
|
|
194
209
|
guestPath: string,
|
|
195
210
|
onConnection: (connection: ImpRelayConnection) => void,
|
|
196
211
|
): ImpReverseForward => {
|
|
212
|
+
const token = this.tryReadToken();
|
|
213
|
+
|
|
214
|
+
if (token instanceof ImpPortError) {
|
|
215
|
+
return { listening: Promise.reject(token), stop: () => {} };
|
|
216
|
+
}
|
|
217
|
+
|
|
197
218
|
const forward = openReverseForward({
|
|
198
219
|
baseUrl: this.url,
|
|
199
|
-
token
|
|
220
|
+
token,
|
|
200
221
|
name,
|
|
201
222
|
guest: { network: 'unix', path: guestPath },
|
|
202
|
-
|
|
223
|
+
|
|
224
|
+
// The client reuses the headers of the listening socket for every
|
|
225
|
+
// guest connection relay, so each socket reads the token afresh. A
|
|
226
|
+
// token that cannot be read dials without one, which impd refuses.
|
|
227
|
+
connect: (url, headers) => {
|
|
228
|
+
const { authorization: _, ...rest } = headers;
|
|
229
|
+
const current = this.tryReadToken();
|
|
230
|
+
|
|
231
|
+
return new WebSocket(url, {
|
|
232
|
+
headers:
|
|
233
|
+
typeof current === 'string' ? { ...rest, authorization: `Bearer ${current}` } : rest,
|
|
234
|
+
});
|
|
235
|
+
},
|
|
203
236
|
onConnection: (accept) => {
|
|
204
237
|
onConnection(openRelay(accept));
|
|
205
238
|
},
|
|
@@ -215,13 +248,36 @@ export class ImpClientPort implements ImpPort {
|
|
|
215
248
|
|
|
216
249
|
// Runs one client call, turning impd's refusals and transport failures
|
|
217
250
|
// into port errors.
|
|
218
|
-
private async tryCall<T>(call: () => Promise<T>): Promise<T> {
|
|
251
|
+
private async tryCall<T>(call: (client: ImpClient) => Promise<T>): Promise<T> {
|
|
219
252
|
try {
|
|
220
|
-
return await call();
|
|
253
|
+
return await call(this.loadClient());
|
|
221
254
|
} catch (error) {
|
|
222
255
|
throw toPortError(error);
|
|
223
256
|
}
|
|
224
257
|
}
|
|
258
|
+
|
|
259
|
+
// The token as it reads now, or the port error its read throws.
|
|
260
|
+
private tryReadToken(): string | null | ImpPortError {
|
|
261
|
+
try {
|
|
262
|
+
return this.readToken();
|
|
263
|
+
} catch (error) {
|
|
264
|
+
return toPortError(error);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// The client that calls impd with the token as it reads now.
|
|
269
|
+
private loadClient(): ImpClient {
|
|
270
|
+
const token = this.readToken();
|
|
271
|
+
|
|
272
|
+
if (this.client === null || this.client.token !== token) {
|
|
273
|
+
this.client = {
|
|
274
|
+
token,
|
|
275
|
+
client: createImpClient({ url: this.url, ...(token === null ? {} : { token }) }),
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
return this.client.client;
|
|
280
|
+
}
|
|
225
281
|
}
|
|
226
282
|
|
|
227
283
|
// A lease as the client returns it.
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { DaemonError } from '../protocol/daemon-error';
|
|
2
|
+
import { LineDecoder } from '../protocol/line-decoder';
|
|
2
3
|
import { isCompiledBinary } from '../shared/is-compiled-binary';
|
|
3
4
|
import { buildTarArchive } from './build-tar-archive';
|
|
4
5
|
import type {
|
|
@@ -523,22 +524,15 @@ function buildSocketName(sessionID: string): string {
|
|
|
523
524
|
|
|
524
525
|
// Frames a guest connection's bytes into lines, both ways.
|
|
525
526
|
function toHarnessRelay(connection: ImpRelayConnection): HarnessRelay {
|
|
526
|
-
const decoder = new
|
|
527
|
+
const decoder = new LineDecoder();
|
|
527
528
|
const encoder = new TextEncoder();
|
|
528
529
|
|
|
529
530
|
const lineListeners: ((line: string) => void)[] = [];
|
|
530
|
-
let pending = '';
|
|
531
531
|
|
|
532
532
|
connection.onData((data) => {
|
|
533
|
-
const
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
for (const line of lines) {
|
|
538
|
-
if (line.trim() !== '') {
|
|
539
|
-
for (const listener of lineListeners) {
|
|
540
|
-
listener(line);
|
|
541
|
-
}
|
|
533
|
+
for (const line of decoder.splitChunk(data)) {
|
|
534
|
+
for (const listener of lineListeners) {
|
|
535
|
+
listener(line);
|
|
542
536
|
}
|
|
543
537
|
}
|
|
544
538
|
});
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { mkdtemp, rm } from 'node:fs/promises';
|
|
2
|
-
import { tmpdir } from 'node:os';
|
|
3
2
|
import { dirname, join, resolve } from 'node:path';
|
|
4
3
|
import { DaemonError } from '../protocol/daemon-error';
|
|
5
4
|
import type { ErrorCode } from '../protocol/protocol';
|
|
@@ -36,6 +35,10 @@ interface MaterializeDeps {
|
|
|
36
35
|
readonly requireProvider: (capability: 'run' | 'transfer') => ExecutionProvider;
|
|
37
36
|
readonly store: Pick<StateStore, 'createMaterialization' | 'updateMaterialization'>;
|
|
38
37
|
readonly log: (line: string) => void;
|
|
38
|
+
|
|
39
|
+
// The directory on the daemon's host that holds each clone's staging
|
|
40
|
+
// directory while the workspace is built.
|
|
41
|
+
readonly stagingRoot: string;
|
|
39
42
|
}
|
|
40
43
|
|
|
41
44
|
type MaterializedWorkspace = { readonly kind: 'in_place' } | ReadyWorkspace;
|
|
@@ -122,7 +125,7 @@ export async function materializeWorkspace(
|
|
|
122
125
|
// The staging directory exists only once the row does, and only inside
|
|
123
126
|
// the block that removes it, so neither can outlive a failure of the other.
|
|
124
127
|
try {
|
|
125
|
-
const staging = await mkdtemp(join(
|
|
128
|
+
const staging = await mkdtemp(join(deps.stagingRoot, 'atc-workspace-'));
|
|
126
129
|
|
|
127
130
|
try {
|
|
128
131
|
const ready = await runMaterialization(request, deps, staging, updateProgress, secret);
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { ImpPortError } from './imp-port-error';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The impd token held in the file at `path`, without one trailing newline.
|
|
6
|
+
* An empty or unreadable file throws an `UNAUTHORIZED` port error whose
|
|
7
|
+
* message holds the path and the read's error code, never file content.
|
|
8
|
+
*/
|
|
9
|
+
export function readImpToken(path: string): string {
|
|
10
|
+
let text: string;
|
|
11
|
+
|
|
12
|
+
try {
|
|
13
|
+
text = readFileSync(path, 'utf8');
|
|
14
|
+
} catch (error) {
|
|
15
|
+
const code =
|
|
16
|
+
error instanceof Error && 'code' in error && typeof error.code === 'string'
|
|
17
|
+
? error.code
|
|
18
|
+
: 'unknown error';
|
|
19
|
+
|
|
20
|
+
throw new ImpPortError('UNAUTHORIZED', `cannot read the impd token file ${path}: ${code}`);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const token = text.endsWith('\n') ? text.slice(0, -1) : text;
|
|
24
|
+
|
|
25
|
+
if (token === '') {
|
|
26
|
+
throw new ImpPortError('UNAUTHORIZED', `the impd token file ${path} is empty`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return token;
|
|
30
|
+
}
|
package/src/daemon/sessions.ts
CHANGED
|
@@ -232,6 +232,10 @@ export class SessionManager {
|
|
|
232
232
|
// of the same session does not start a second harness.
|
|
233
233
|
private readonly adopting = new Set<SessionID>();
|
|
234
234
|
|
|
235
|
+
// Sessions dropped from the list on purpose whose rows the next fleet
|
|
236
|
+
// write deletes; each stays here until a write carrying it lands.
|
|
237
|
+
private readonly removedIDs = new Set<SessionID>();
|
|
238
|
+
|
|
235
239
|
// The epoch the next harness start or attach takes, unique across every
|
|
236
240
|
// session this manager holds.
|
|
237
241
|
private nextBridgeEpoch = 1;
|
|
@@ -1350,6 +1354,8 @@ export class SessionManager {
|
|
|
1350
1354
|
private remove(s: Session) {
|
|
1351
1355
|
this.sessions = this.sessions.filter((x) => x.id !== s.id);
|
|
1352
1356
|
|
|
1357
|
+
this.removedIDs.add(s.id);
|
|
1358
|
+
|
|
1353
1359
|
if (this.focusedId === s.id) {
|
|
1354
1360
|
this.focusedId = null;
|
|
1355
1361
|
}
|
|
@@ -1383,8 +1389,11 @@ export class SessionManager {
|
|
|
1383
1389
|
}
|
|
1384
1390
|
|
|
1385
1391
|
// Persisted continuously so a crash (or quit) leaves a restorable fleet.
|
|
1386
|
-
//
|
|
1387
|
-
//
|
|
1392
|
+
// A write covers the listed sessions and the ones dropped on purpose
|
|
1393
|
+
// since the last write; a stored row the daemon has not restored yet
|
|
1394
|
+
// stays as it is, so a write before `R` keeps it restorable. Deliberate
|
|
1395
|
+
// kills rewrite the fleet; unexpected session/atc deaths do not, so the
|
|
1396
|
+
// last known fleet survives for `R` restore. Sessions whose
|
|
1388
1397
|
// terminal is gone persist as exited entries, so the killed archive
|
|
1389
1398
|
// survives a daemon restart too. A session the agent has not yet given
|
|
1390
1399
|
// its own session id persists as well, under its atc session id alone.
|
|
@@ -1416,7 +1425,13 @@ export class SessionManager {
|
|
|
1416
1425
|
});
|
|
1417
1426
|
}
|
|
1418
1427
|
|
|
1419
|
-
|
|
1428
|
+
const removed = [...this.removedIDs];
|
|
1429
|
+
|
|
1430
|
+
await this.store.writeFleet(fleet, removed);
|
|
1431
|
+
|
|
1432
|
+
for (const id of removed) {
|
|
1433
|
+
this.removedIDs.delete(id);
|
|
1434
|
+
}
|
|
1420
1435
|
}
|
|
1421
1436
|
|
|
1422
1437
|
// A write nobody awaits that fails leaves the stored rows as they were,
|
package/src/mcp/mcp-tools.ts
CHANGED
|
@@ -115,6 +115,13 @@ const MESSAGE_GET_INPUT: Readonly<Record<string, unknown>> = z.toJSONSchema(
|
|
|
115
115
|
{ io: 'input' },
|
|
116
116
|
);
|
|
117
117
|
|
|
118
|
+
const REPORT_GET_INPUT: Readonly<Record<string, unknown>> = z.toJSONSchema(
|
|
119
|
+
z.strictObject({
|
|
120
|
+
report: z.string().describe("The cursor of the report's event, from atc_events_read"),
|
|
121
|
+
}),
|
|
122
|
+
{ io: 'input' },
|
|
123
|
+
);
|
|
124
|
+
|
|
118
125
|
// Output schemas leave further properties open, so a field the daemon adds
|
|
119
126
|
// later never fails a client that validates results against them.
|
|
120
127
|
const MESSAGE_OUTPUT: Readonly<Record<string, unknown>> = {
|
|
@@ -277,6 +284,20 @@ const EVENTS_OUTPUT: Readonly<Record<string, unknown>> = {
|
|
|
277
284
|
required: ['events', 'cursor', 'more'],
|
|
278
285
|
};
|
|
279
286
|
|
|
287
|
+
const REPORT_OUTPUT: Readonly<Record<string, unknown>> = {
|
|
288
|
+
type: 'object',
|
|
289
|
+
properties: {
|
|
290
|
+
report: { type: 'string' },
|
|
291
|
+
at: { type: 'number' },
|
|
292
|
+
session: { type: 'string' },
|
|
293
|
+
name: { type: ['string', 'null'] },
|
|
294
|
+
label: { type: 'string' },
|
|
295
|
+
text: { type: 'string' },
|
|
296
|
+
complete: { type: 'boolean' },
|
|
297
|
+
},
|
|
298
|
+
required: ['report', 'at', 'session', 'name', 'label', 'text', 'complete'],
|
|
299
|
+
};
|
|
300
|
+
|
|
280
301
|
interface MCPToolAnnotations {
|
|
281
302
|
readonly readOnlyHint: boolean;
|
|
282
303
|
readonly destructiveHint: boolean;
|
|
@@ -464,11 +485,21 @@ export const MCP_TOOLS: readonly MCPToolDefinition[] = [
|
|
|
464
485
|
annotations: READ_ONLY,
|
|
465
486
|
scope: 'read',
|
|
466
487
|
description:
|
|
467
|
-
'Catch up on the fleet: session events (started, prompt-submitted, needs-input, turn-done, ended), message events (message-accepted, message-delivered, message-answered), and reports (report) since a cursor, oldest first, each with the session id and name. A message event carries the message id; read the full message with atc_message_get. A report event carries its label. Without a cursor it returns the most recent events. Pass the returned cursor next time; more is true when the page stopped before the newest event, so read again at once. session limits the read to one session. waitMs holds the call open until an event arrives; pass it instead of polling in a tight loop.',
|
|
488
|
+
'Catch up on the fleet: session events (started, prompt-submitted, needs-input, turn-done, ended), message events (message-accepted, message-delivered, message-answered), and reports (report) since a cursor, oldest first, each with the session id and name. A message event carries the message id; read the full message with atc_message_get. A report event carries its label and a preview of its text; read the full text with atc_report_get, passing the cursor of that event. Without a cursor it returns the most recent events. Pass the returned cursor next time; more is true when the page stopped before the newest event, so read again at once. session limits the read to one session. waitMs holds the call open until an event arrives; pass it instead of polling in a tight loop.',
|
|
468
489
|
inputSchema: EVENTS_READ_INPUT,
|
|
469
490
|
outputSchema: EVENTS_OUTPUT,
|
|
470
491
|
requires: { output: 'events.more', inputs: { session: 'events.session' } },
|
|
471
492
|
},
|
|
493
|
+
{
|
|
494
|
+
name: 'atc_report_get',
|
|
495
|
+
annotations: READ_ONLY,
|
|
496
|
+
scope: 'read',
|
|
497
|
+
description:
|
|
498
|
+
"Read one report's full text without messaging the session that sent it. Pass the cursor of the report's event from atc_events_read. Returns the report cursor, at, the session id and name, the label, the text (up to 64 KiB, as the session sent it), and complete, which is false for a report recorded before atc kept full texts: its text is then only the preview the event held. A cursor of an event that is not a report answers as an unknown report.",
|
|
499
|
+
inputSchema: REPORT_GET_INPUT,
|
|
500
|
+
outputSchema: REPORT_OUTPUT,
|
|
501
|
+
requires: { tool: 'report.get' },
|
|
502
|
+
},
|
|
472
503
|
{
|
|
473
504
|
name: 'atc_session_message',
|
|
474
505
|
annotations: AGENT_FACING,
|
|
@@ -16,6 +16,7 @@ const FEATURE_USES: Readonly<Record<DaemonFeature, string>> = {
|
|
|
16
16
|
'spawn.idempotency': "atc_session_spawn's idempotencyKey",
|
|
17
17
|
'spawn.options': "atc_session_spawn's model and effort",
|
|
18
18
|
'spawn.target': "atc_session_spawn's target",
|
|
19
|
+
'report.get': 'atc_report_get',
|
|
19
20
|
'request.principal': 'the target limits of a remote MCP client',
|
|
20
21
|
'spawn.workspace': "atc_session_spawn's workspace",
|
|
21
22
|
};
|
package/src/mcp/run-tool.ts
CHANGED
|
@@ -172,6 +172,11 @@ export function runTool(
|
|
|
172
172
|
|
|
173
173
|
return buildObjectResult(ok);
|
|
174
174
|
})
|
|
175
|
+
.with('atc_report_get', async () => {
|
|
176
|
+
const ok = await caller.sendRequest('report.get', { report: args['report'] }, ['report.get']);
|
|
177
|
+
|
|
178
|
+
return buildObjectResult(ok);
|
|
179
|
+
})
|
|
175
180
|
.with('atc_session_message', async () => {
|
|
176
181
|
const given = args['from'];
|
|
177
182
|
|
package/src/mcp-server.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { bootDaemonClient } from './client/boot-daemon';
|
|
|
2
2
|
import { answerRPCRequest } from './mcp/answer-rpc-request';
|
|
3
3
|
import { requireDaemonFeatures } from './mcp/require-daemon-features';
|
|
4
4
|
import type { FleetCaller, ToolContext } from './mcp/types';
|
|
5
|
+
import { LineDecoder } from './protocol/line-decoder';
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* An MCP server over stdio bridging to the atc daemon: any MCP client —
|
|
@@ -34,26 +35,14 @@ export async function runMCPServer(build: string): Promise<void> {
|
|
|
34
35
|
sender: { kind: 'default', name: callerSessionID ?? 'mcp' },
|
|
35
36
|
};
|
|
36
37
|
|
|
37
|
-
const decoder = new
|
|
38
|
-
|
|
39
|
-
let buffer = '';
|
|
38
|
+
const decoder = new LineDecoder();
|
|
40
39
|
|
|
41
40
|
// Each request runs on its own, so a long poll never holds up the others;
|
|
42
41
|
// responses carry their request's id.
|
|
43
42
|
const inFlight = new Set<Promise<void>>();
|
|
44
43
|
|
|
45
44
|
for await (const chunk of Bun.stdin.stream()) {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
const lines = buffer.split('\n');
|
|
49
|
-
|
|
50
|
-
buffer = lines.pop() ?? '';
|
|
51
|
-
|
|
52
|
-
for (const line of lines) {
|
|
53
|
-
if (line.trim() === '') {
|
|
54
|
-
continue;
|
|
55
|
-
}
|
|
56
|
-
|
|
45
|
+
for (const line of decoder.splitChunk(chunk)) {
|
|
57
46
|
const finished = Promise.withResolvers<void>();
|
|
58
47
|
|
|
59
48
|
inFlight.add(finished.promise);
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Splits one stream of newline-framed text into whole lines. Bytes decode
|
|
3
|
+
* as UTF-8 with state kept across reads, so a multi-byte character split
|
|
4
|
+
* between two reads decodes whole. The unterminated tail waits for the read
|
|
5
|
+
* that ends it, and a line holding only whitespace is dropped. A carriage
|
|
6
|
+
* return before the newline stays on the line.
|
|
7
|
+
*/
|
|
8
|
+
export class LineDecoder {
|
|
9
|
+
private readonly decoder = new TextDecoder();
|
|
10
|
+
|
|
11
|
+
private pending = '';
|
|
12
|
+
|
|
13
|
+
get pendingLength(): number {
|
|
14
|
+
return this.pending.length;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// oxlint-disable-next-line prefer-readonly-parameter-types -- a socket read buffer has no readonly form
|
|
18
|
+
splitChunk(bytes: Uint8Array): string[] {
|
|
19
|
+
return this.splitText(this.decodeText(bytes));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// oxlint-disable-next-line prefer-readonly-parameter-types -- a socket read buffer has no readonly form
|
|
23
|
+
decodeText(bytes: Uint8Array): string {
|
|
24
|
+
return this.decoder.decode(bytes, { stream: true });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
splitText(text: string): string[] {
|
|
28
|
+
const lines = `${this.pending}${text}`.split('\n');
|
|
29
|
+
|
|
30
|
+
this.pending = lines.pop() ?? '';
|
|
31
|
+
|
|
32
|
+
return lines.filter((line) => line.trim() !== '');
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { LineDecoder } from './line-decoder';
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* One line-framed connection to a session bridge, from inside a remote
|
|
3
5
|
* session: each line the bridge writes arrives whole, and each line written
|
|
@@ -20,10 +22,9 @@ export async function openBridgeSocket(
|
|
|
20
22
|
onLine: (line: Readonly<Record<string, unknown>>) => void,
|
|
21
23
|
): Promise<BridgeSocket> {
|
|
22
24
|
const encoder = new TextEncoder();
|
|
23
|
-
const decoder = new
|
|
25
|
+
const decoder = new LineDecoder();
|
|
24
26
|
|
|
25
27
|
const closed = Promise.withResolvers<void>();
|
|
26
|
-
let pending = '';
|
|
27
28
|
|
|
28
29
|
let unsent = new Uint8Array(0);
|
|
29
30
|
|
|
@@ -31,11 +32,7 @@ export async function openBridgeSocket(
|
|
|
31
32
|
unix: path,
|
|
32
33
|
socket: {
|
|
33
34
|
data(_socket, buf) {
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
pending = lines.pop() ?? '';
|
|
37
|
-
|
|
38
|
-
for (const line of lines) {
|
|
35
|
+
for (const line of decoder.splitChunk(buf)) {
|
|
39
36
|
const parsed = parseLine(line);
|
|
40
37
|
|
|
41
38
|
if (parsed !== null) {
|
|
@@ -216,6 +216,12 @@ export const REQUEST_PARAM_SCHEMAS = {
|
|
|
216
216
|
waitMs: buildDefaultedWait(),
|
|
217
217
|
})
|
|
218
218
|
.refine((v) => v.message !== '', { message: 'message.get requires a message' }),
|
|
219
|
+
'report.get': z
|
|
220
|
+
.object({
|
|
221
|
+
// The cursor events.read returned with the report's event.
|
|
222
|
+
report: buildDefaultedString(''),
|
|
223
|
+
})
|
|
224
|
+
.refine((v) => v.report !== '', { message: 'report.get requires a report' }),
|
|
219
225
|
'message.ack': SESSION_DEFAULTED.extend({
|
|
220
226
|
message: buildDefaultedString('').transform(toMessageID),
|
|
221
227
|
}).refine((v) => v.message !== '', { message: 'message.ack requires a message' }),
|
package/src/report.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
|
+
import { sendBridgeRequest } from './protocol/send-bridge-request';
|
|
4
5
|
import { sendReport } from './shared/report';
|
|
5
6
|
import { REPORT_KINDS } from './shared/report-kinds';
|
|
6
|
-
import { sendBridgeRequest } from './shared/send-bridge-request';
|
|
7
7
|
|
|
8
8
|
interface ReportOptions {
|
|
9
9
|
readonly message: string;
|
package/src/run-bridge-tap.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { readFileSync, readdirSync, rmSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { z } from 'zod';
|
|
4
|
-
import { openBridgeSocket } from './
|
|
5
|
-
import type { BridgeSocket } from './
|
|
4
|
+
import { openBridgeSocket } from './protocol/open-bridge-socket';
|
|
5
|
+
import type { BridgeSocket } from './protocol/open-bridge-socket';
|
|
6
6
|
|
|
7
7
|
const INBOX_MESSAGE_SCHEMA = z.looseObject({
|
|
8
8
|
ev: z.literal('InboxMessage'),
|
package/src/statusline.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs';
|
|
2
2
|
import { homedir } from 'node:os';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
|
+
import { sendBridgeRequest } from './protocol/send-bridge-request';
|
|
4
5
|
import { statusFile } from './shared/config';
|
|
5
6
|
import { isRecord, sendReport } from './shared/report';
|
|
6
|
-
import { sendBridgeRequest } from './shared/send-bridge-request';
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* Runs as the statusLine command injected into wrangled sessions. Chains the
|
package/src/store/fleet-entry.ts
CHANGED
|
@@ -72,7 +72,10 @@ export interface FleetEntry {
|
|
|
72
72
|
export interface FleetStore {
|
|
73
73
|
readonly daemonID: DaemonID;
|
|
74
74
|
readonly loadFleet: () => Promise<FleetEntry[]>;
|
|
75
|
-
readonly writeFleet: (
|
|
75
|
+
readonly writeFleet: (
|
|
76
|
+
entries: readonly FleetEntry[],
|
|
77
|
+
removed?: readonly SessionID[],
|
|
78
|
+
) => Promise<void>;
|
|
76
79
|
readonly updateFleetEntry: (sessionID: SessionID, fields: FleetEntryUpdate) => Promise<void>;
|
|
77
80
|
}
|
|
78
81
|
|