@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.
Files changed (35) hide show
  1. package/README.md +2 -1
  2. package/package.json +1 -1
  3. package/src/client/boot-daemon.ts +47 -21
  4. package/src/client/daemon-client.ts +5 -22
  5. package/src/client/format-protocol-mismatch.ts +37 -0
  6. package/src/client/index.ts +54 -1
  7. package/src/daemon/build-imp-provider.ts +70 -23
  8. package/src/daemon/build-report-trail-entry.ts +4 -2
  9. package/src/daemon/build-report-view.ts +41 -0
  10. package/src/daemon/build-scoped-context.ts +5 -0
  11. package/src/daemon/daemon-connection.ts +42 -16
  12. package/src/daemon/daemon.ts +8 -1
  13. package/src/daemon/hooks.ts +5 -17
  14. package/src/daemon/imp-client-port.ts +83 -27
  15. package/src/daemon/imp-provider.ts +5 -11
  16. package/src/daemon/materialize-workspace.ts +5 -2
  17. package/src/daemon/read-imp-token.ts +30 -0
  18. package/src/daemon/sessions.ts +18 -3
  19. package/src/mcp/mcp-tools.ts +32 -1
  20. package/src/mcp/reconnecting-caller.ts +1 -0
  21. package/src/mcp/require-daemon-features.ts +1 -0
  22. package/src/mcp/run-tool.ts +5 -0
  23. package/src/mcp-server.ts +3 -14
  24. package/src/protocol/daemon-features.ts +3 -0
  25. package/src/protocol/line-decoder.ts +34 -0
  26. package/src/{shared → protocol}/open-bridge-socket.ts +4 -7
  27. package/src/protocol/request-param-schemas.ts +6 -0
  28. package/src/report.ts +1 -1
  29. package/src/run-bridge-tap.ts +2 -2
  30. package/src/statusline.ts +1 -1
  31. package/src/store/fleet-entry.ts +4 -1
  32. package/src/store/run-migrations.ts +9 -0
  33. package/src/store/state-store.ts +119 -15
  34. package/src/store/trail-entry.ts +3 -0
  35. /package/src/{shared → protocol}/send-bridge-request.ts +0 -0
@@ -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 unterminated tail of the line in progress,
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
- pending: string;
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 conn = socket.data;
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 = { pending: '', decoder: new TextDecoder() };
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 token the daemon calls it with. The token
22
- * comes from the daemon's environment, never from a config file.
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 token: string | null;
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 token: string | null;
41
+ private readonly readToken: () => string | null;
39
42
 
40
- private readonly client: ImpClient;
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.token = options.token;
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(() => this.client.system.info());
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(() => this.client.imps.get({ name }));
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
- this.client.imps.create({
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(() => this.client.leases.acquire({ name, label, ttlSeconds }));
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(() => this.client.leases.renew({ name, label, ttlSeconds }));
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(() => this.client.leases.release({ name, label }));
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(() => this.client.imps.sleep({ name }));
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(() => this.client.imps.destroy({ name }));
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: this.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
- this.client.run(name, command.argv, {
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: this.token,
220
+ token,
200
221
  name,
201
222
  guest: { network: 'unix', path: guestPath },
202
- connect: (url, headers) => new WebSocket(url, { headers }),
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 TextDecoder();
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 lines = `${pending}${decoder.decode(data, { stream: true })}`.split('\n');
534
-
535
- pending = lines.pop() ?? '';
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(tmpdir(), 'atc-workspace-'));
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
+ }
@@ -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
- // Deliberate kills rewrite the file; unexpected session/atc deaths do not,
1387
- // so the last known fleet survives for `R` restore. Sessions whose
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
- await this.store.writeFleet(fleet);
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,
@@ -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,
@@ -12,6 +12,7 @@ const RETRYABLE_METHODS: ReadonlySet<string> = new Set([
12
12
  'dirs.list',
13
13
  'events.read',
14
14
  'message.get',
15
+ 'report.get',
15
16
  'session.get',
16
17
  'session.list',
17
18
  'session.read',
@@ -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
  };
@@ -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 TextDecoder('utf-8');
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
- buffer += decoder.decode(chunk, { stream: true });
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);
@@ -53,6 +53,9 @@ export const DAEMON_FEATURES = [
53
53
 
54
54
  // `session.submit` exists.
55
55
  'session.submit',
56
+
57
+ // `report.get` exists.
58
+ 'report.get',
56
59
  ] as const;
57
60
 
58
61
  export type DaemonFeature = (typeof DAEMON_FEATURES)[number];
@@ -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 TextDecoder();
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 lines = `${pending}${decoder.decode(buf, { stream: true })}`.split('\n');
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;
@@ -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 './shared/open-bridge-socket';
5
- import type { BridgeSocket } from './shared/open-bridge-socket';
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
@@ -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: (entries: readonly FleetEntry[]) => Promise<void>;
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