@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
package/README.md CHANGED
@@ -94,7 +94,8 @@ list.
94
94
  If the daemon dies, press `R` on the home screen and every session respawns from its transcript.
95
95
  After you upgrade atc, the status bar shows `⟳ update ready`, and `u` restarts the daemon and
96
96
  restores the fleet when you are ready. The bar shows `⟳ restarting daemon` until the restart
97
- finishes.
97
+ finishes. When the running daemon speaks another protocol version, `atc` asks before it restarts the
98
+ daemon, since the restart ends every session the daemon hosts.
98
99
 
99
100
  atc runs inside zellij or tmux. Give the pane locked mode so the leader key reaches atc.
100
101
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zgeoff/atc",
3
- "version": "2.13.0",
3
+ "version": "2.15.0",
4
4
  "description": "Terminal control tower for coding-agent sessions",
5
5
  "homepage": "https://github.com/zgeoff/atc#readme",
6
6
  "bugs": "https://github.com/zgeoff/atc/issues",
@@ -4,15 +4,18 @@ import { readFileSync } from 'node:fs';
4
4
  import { join } from 'node:path';
5
5
  import { toAgentID } from '../agents/agent-adapter';
6
6
  import type { AgentID } from '../agents/agent-adapter';
7
+ import { DaemonError } from '../protocol/daemon-error';
7
8
  import type { DaemonFeature } from '../protocol/daemon-features';
8
9
  import { parseDaemonFeatures } from '../protocol/parse-daemon-features';
10
+ import { PROTOCOL_V } from '../protocol/protocol';
9
11
  import { daemonPidFile, daemonRecordFile, daemonSocketPath } from '../shared/config';
10
12
  import { findDaemonRecord } from '../shared/find-daemon-record';
11
13
  import { getBuild } from '../shared/get-build';
12
14
  import { isCompiledBinary } from '../shared/is-compiled-binary';
13
15
  import { makeSingleFlight } from '../shared/make-single-flight';
14
- import { isRecord } from '../shared/report';
15
16
  import { DaemonClient } from './daemon-client';
17
+ import { formatProtocolMismatch } from './format-protocol-mismatch';
18
+ import type { ProtocolMismatch } from './format-protocol-mismatch';
16
19
  import { pickStaleDaemonPID } from './pick-stale-daemon-pid';
17
20
 
18
21
  export interface DaemonBoot {
@@ -28,18 +31,28 @@ export interface DaemonBoot {
28
31
  readonly socketPath: string;
29
32
  }
30
33
 
34
+ export interface DaemonBootOptions {
35
+ // Called when the daemon speaks another protocol version. Resolving true
36
+ // stops that daemon and boots one from this build, which ends every
37
+ // session it hosts; without the callback, or resolving false, the boot
38
+ // rejects and the daemon keeps running.
39
+ readonly onProtocolMismatch?: (mismatch: ProtocolMismatch) => Promise<boolean>;
40
+ }
41
+
31
42
  /**
32
43
  * Opens a handshaken client to the daemon, booting the daemon first when
33
44
  * neither the computed socket nor the one in the daemon's record answers.
34
- * Overlapping calls in one process share a single boot. A daemon from an older build stays in service — killing
35
- * it would kill every hosted session — and is reported as stale so the
36
- * caller can offer a deliberate restart. Only a protocol mismatch, where
37
- * talking would misbehave, forces the restart immediately. The expected
38
- * build is read from disk on every attempt: a long-lived caller holding a
39
- * build string from its own boot would otherwise flag daemons that are
40
- * already current.
45
+ * Overlapping calls in one process share a single boot. A daemon from an
46
+ * older build stays in service, since stopping it would end every hosted
47
+ * session, and is reported as stale so the caller can offer a deliberate
48
+ * restart. A daemon on another protocol version stays in service too: the
49
+ * boot rejects with `protocol_mismatch` and a message holding both builds,
50
+ * both versions, and the way to restart it, unless the caller's
51
+ * `onProtocolMismatch` confirms a restart. The expected build is read from
52
+ * disk on every attempt: a long-lived caller holding a build string from
53
+ * its own boot would otherwise flag daemons that are already current.
41
54
  */
42
- export async function bootDaemonClient(): Promise<DaemonBoot> {
55
+ export async function bootDaemonClient(options: DaemonBootOptions = {}): Promise<DaemonBoot> {
43
56
  for (let attempt = 0; attempt < 2; attempt++) {
44
57
  const build = getBuild();
45
58
 
@@ -60,21 +73,35 @@ export async function bootDaemonClient(): Promise<DaemonBoot> {
60
73
  } catch (error) {
61
74
  client.stop();
62
75
 
63
- if (attempt > 0 || !isProtocolMismatch(error)) {
76
+ if (attempt > 0 || !(error instanceof DaemonError) || error.code !== 'protocol_mismatch') {
64
77
  throw error;
65
78
  }
66
79
 
67
- await stopStaleDaemon(opened.socketPath);
80
+ const mismatch: ProtocolMismatch = {
81
+ socketPath: opened.socketPath,
82
+ daemonPID: findDaemonPID(opened.socketPath),
83
+ clientBuild: build,
84
+ clientProtocol: PROTOCOL_V,
85
+ daemonMessage: error.message,
86
+ };
87
+
88
+ // Without a pid there is no daemon this client could stop, so the
89
+ // caller is not asked.
90
+ if (
91
+ mismatch.daemonPID === null ||
92
+ options.onProtocolMismatch === undefined ||
93
+ !(await options.onProtocolMismatch(mismatch))
94
+ ) {
95
+ throw new DaemonError('protocol_mismatch', formatProtocolMismatch(mismatch));
96
+ }
97
+
98
+ await stopDaemon(mismatch.daemonPID);
68
99
  }
69
100
  }
70
101
 
71
102
  throw new Error('the atc daemon could not be restarted');
72
103
  }
73
104
 
74
- function isProtocolMismatch(error: unknown): boolean {
75
- return isRecord(error) && error['code'] === 'protocol_mismatch';
76
- }
77
-
78
105
  interface OpenedDaemon {
79
106
  readonly client: DaemonClient;
80
107
  readonly socketPath: string;
@@ -177,18 +204,17 @@ function isProcessAlive(pid: number): boolean {
177
204
  }
178
205
  }
179
206
 
180
- async function stopStaleDaemon(socketPath: string): Promise<void> {
181
- const pid = pickStaleDaemonPID({
207
+ // The pid of the daemon behind the socket that refused the handshake.
208
+ function findDaemonPID(socketPath: string): number | null {
209
+ return pickStaleDaemonPID({
182
210
  socketPath,
183
211
  record: findDaemonRecord(daemonRecordFile),
184
212
  pidFileSocketPath: daemonSocketPath,
185
213
  pidFilePID: findPidFilePID(),
186
214
  });
215
+ }
187
216
 
188
- if (pid === null) {
189
- return;
190
- }
191
-
217
+ async function stopDaemon(pid: number): Promise<void> {
192
218
  try {
193
219
  process.kill(pid, 'SIGTERM');
194
220
  } catch {
@@ -1,4 +1,5 @@
1
1
  import { DaemonError } from '../protocol/daemon-error';
2
+ import { LineDecoder } from '../protocol/line-decoder';
2
3
  import { OutboundQueue } from '../protocol/outbound-queue';
3
4
  import { PROTOCOL_V, decodeMessage, encodeMessage } from '../protocol/protocol';
4
5
  import type { EventMsg, ResponseMsg } from '../protocol/protocol';
@@ -20,9 +21,7 @@ export class DaemonClient {
20
21
 
21
22
  private queue: OutboundQueue | null = null;
22
23
 
23
- private buffer = '';
24
-
25
- private readonly decoder = new TextDecoder();
24
+ private readonly lines = new LineDecoder();
26
25
 
27
26
  private nextID = 1;
28
27
 
@@ -41,7 +40,7 @@ export class DaemonClient {
41
40
  unix: socketPath,
42
41
  socket: {
43
42
  data(_s, buf) {
44
- client.applyChunk(client.decodeChunk(buf));
43
+ client.applyChunk(buf);
45
44
  },
46
45
  drain() {
47
46
  client.queue?.drain();
@@ -99,25 +98,9 @@ export class DaemonClient {
99
98
  this.drainPending('client closed');
100
99
  }
101
100
 
102
- // Decodes with state kept across reads, so a multi-byte character split
103
- // between two reads decodes whole.
104
101
  // oxlint-disable-next-line prefer-readonly-parameter-types -- a socket read buffer has no readonly form
105
- private decodeChunk(buf: Uint8Array): string {
106
- return this.decoder.decode(buf, { stream: true });
107
- }
108
-
109
- private applyChunk(chunk: string): void {
110
- this.buffer += chunk;
111
-
112
- const lines = this.buffer.split('\n');
113
-
114
- this.buffer = lines.pop() ?? '';
115
-
116
- for (const line of lines) {
117
- if (line.trim() === '') {
118
- continue;
119
- }
120
-
102
+ private applyChunk(buf: Uint8Array): void {
103
+ for (const line of this.lines.splitChunk(buf)) {
121
104
  this.applyLine(line);
122
105
  }
123
106
  }
@@ -0,0 +1,37 @@
1
+ // A daemon that refused this client's handshake because it speaks another
2
+ // protocol version.
3
+ export interface ProtocolMismatch {
4
+ readonly socketPath: string;
5
+
6
+ // The daemon's pid from its record or pid file, or null when neither
7
+ // belongs to the socket that refused.
8
+ readonly daemonPID: number | null;
9
+
10
+ readonly clientBuild: string;
11
+ readonly clientProtocol: number;
12
+
13
+ // The daemon's own refusal, which holds its build and protocol version.
14
+ readonly daemonMessage: string;
15
+ }
16
+
17
+ /**
18
+ * Describes a daemon on another protocol version: both builds and both
19
+ * versions, that it was left running, and how to restart it on purpose.
20
+ */
21
+ export function formatProtocolMismatch(mismatch: ProtocolMismatch): string {
22
+ const pid = mismatch.daemonPID === null ? 'pid unknown' : `pid ${mismatch.daemonPID}`;
23
+
24
+ // Without a pid the TUI cannot stop the daemon either, so the restart is
25
+ // left to the user.
26
+ const restart =
27
+ mismatch.daemonPID === null
28
+ ? `To restart it, find its pid with \`ss -xlp | grep ${mismatch.socketPath}\` on Linux or \`lsof -U | grep ${mismatch.socketPath}\` on macOS, stop that process, and run \`atc\` again: every hosted session ends, and \`R\` respawns them from their transcripts.`
29
+ : 'To restart it, run `atc` from the build you want and confirm its restart prompt: every hosted session ends, and the fleet is restored on the new daemon.';
30
+
31
+ return [
32
+ `the atc daemon (${pid}, socket ${mismatch.socketPath}) speaks another protocol than this client, ${mismatch.clientBuild} on protocol v${mismatch.clientProtocol}.`,
33
+ `The daemon answered: ${mismatch.daemonMessage}`,
34
+ 'It was left running, so the sessions it hosts keep running.',
35
+ restart,
36
+ ].join('\n');
37
+ }
@@ -10,6 +10,7 @@ import { bootDaemonClient } from './boot-daemon';
10
10
  import { buildClientMachine } from './build-client-machine';
11
11
  import { buildLeaderChords } from './build-leader-chords';
12
12
  import { findFuzzyScore, formatDir } from './dirs';
13
+ import type { ProtocolMismatch } from './format-protocol-mismatch';
13
14
  import { KEY, isDown, isUp, planTextEdit } from './keys';
14
15
  import { parseDaemonEvent } from './parse-daemon-event';
15
16
  import { pickTabTarget } from './pick-tab-target';
@@ -781,7 +782,17 @@ const service = createActor(
781
782
  }),
782
783
  );
783
784
 
784
- const boot = await bootDaemonClient();
785
+ // A daemon on another protocol is restarted only when the user confirms
786
+ // it, and the fleet it hosted is then restored on the new one.
787
+ let restartedOnBoot = false;
788
+
789
+ const boot = await bootDaemonClient({
790
+ onProtocolMismatch: async (mismatch) => {
791
+ restartedOnBoot = await waitForRestartConsent(mismatch);
792
+
793
+ return restartedOnBoot;
794
+ },
795
+ });
785
796
 
786
797
  let client = boot.client;
787
798
  let daemonStale = boot.stale;
@@ -790,8 +801,50 @@ let daemonRestarting = false;
790
801
  lastUsedAgent = boot.lastUsedAgent;
791
802
  client.onEvent = applyDaemonEvent;
792
803
 
804
+ if (restartedOnBoot) {
805
+ await sendQuiet('fleet.restore', { cols: cols(), rows: ptyRows() });
806
+ }
807
+
793
808
  await refreshMirror();
794
809
 
810
+ /**
811
+ * Asks on the terminal whether to restart a daemon that speaks another
812
+ * protocol, reading a single key. Anything but `y` declines, and so does a
813
+ * terminal that cannot answer.
814
+ */
815
+ async function waitForRestartConsent(mismatch: ProtocolMismatch): Promise<boolean> {
816
+ if (!process.stdin.isTTY) {
817
+ return false;
818
+ }
819
+
820
+ process.stderr.write(
821
+ [
822
+ `atc: the daemon (pid ${mismatch.daemonPID}) speaks another protocol than this client, ${mismatch.clientBuild} on protocol v${mismatch.clientProtocol}.`,
823
+ `The daemon answered: ${mismatch.daemonMessage}`,
824
+ 'Restart it now? Every session it hosts ends, then the fleet is restored on the new daemon. [y/N] ',
825
+ ].join('\n'),
826
+ );
827
+
828
+ process.stdin.setRawMode(true);
829
+ process.stdin.resume();
830
+
831
+ const key = await new Promise<string>((resolve) => {
832
+ process.stdin.once('data', (buf: Buffer) => {
833
+ resolve(buf.toString());
834
+ });
835
+ });
836
+
837
+ process.stdin.setRawMode(false);
838
+ process.stdin.pause();
839
+
840
+ const confirmed = key === 'y' || key === 'Y';
841
+ const echo = confirmed ? 'y\n' : 'n\n';
842
+
843
+ process.stderr.write(echo);
844
+
845
+ return confirmed;
846
+ }
847
+
795
848
  async function refreshMirror() {
796
849
  const list = await client.sendRequest('session.list');
797
850
 
@@ -1,9 +1,10 @@
1
1
  import { ImpClientPort } from './imp-client-port';
2
2
  import { ImpProvider } from './imp-provider';
3
+ import { readImpToken } from './read-imp-token';
3
4
 
4
5
  /**
5
6
  * The provider of an `imp` target, or the problem that keeps it from one.
6
- * A problem never includes the token or any other environment value.
7
+ * A problem never includes a token or any other environment value.
7
8
  */
8
9
  export interface ImpProviderBuild {
9
10
  readonly provider: ImpProvider | null;
@@ -12,13 +13,16 @@ export interface ImpProviderBuild {
12
13
 
13
14
  /**
14
15
  * The provider of target `id` from its `imp` options: `url` is where impd
15
- * listens, and `tokenEnv` holds the name of the daemon's environment
16
- * variable that carries the token, so no credential sits in the config.
17
- * `image`, `memoryMib`, `guestDir`, and `guestATC` pass through. No
18
- * provider when `url` is missing, so the target lists and refuses every
19
- * spawn. A `tokenEnv` that is not a non-empty string, or whose variable is
20
- * unset or empty in `env`, is a problem and leaves no provider; a target
21
- * without `tokenEnv` calls impd with no token.
16
+ * listens, and at most one of `tokenEnv` and `tokenFile` gives the token,
17
+ * so no credential sits in the config. `tokenEnv` holds the name of the
18
+ * daemon's environment variable that carries the token, read once here.
19
+ * `tokenFile` holds the path of a file that carries it, read here and again
20
+ * before each impd call and connection. `image`, `memoryMib`, `guestDir`,
21
+ * and `guestATC` pass through. No provider when `url` is missing, so the
22
+ * target lists and refuses every spawn. Both token options, either one not
23
+ * a non-empty string, an unset or empty variable, or an empty or unreadable
24
+ * file is a problem and leaves no provider; a target without either calls
25
+ * impd with no token.
22
26
  */
23
27
  export function buildImpProvider(
24
28
  id: string,
@@ -26,29 +30,18 @@ export function buildImpProvider(
26
30
  env: Readonly<Record<string, string | undefined>> = process.env,
27
31
  ): ImpProviderBuild {
28
32
  const url = options['url'];
29
- const tokenEnv = options['tokenEnv'];
30
33
 
31
34
  if (typeof url !== 'string' || url === '') {
32
35
  return { provider: null, problem: null };
33
36
  }
34
37
 
35
- if (tokenEnv !== undefined && (typeof tokenEnv !== 'string' || tokenEnv === '')) {
36
- return {
37
- provider: null,
38
- problem: `target ${JSON.stringify(id)} must give tokenEnv as a non-empty string`,
39
- };
40
- }
41
-
42
- const token = tokenEnv === undefined ? null : env[tokenEnv];
38
+ const source = loadTokenSource(id, options, env);
43
39
 
44
- if (token === undefined || token === '') {
45
- return {
46
- provider: null,
47
- problem: `target ${JSON.stringify(id)} reads its impd token from ${String(tokenEnv)}, which is unset or empty in the daemon's environment`,
48
- };
40
+ if (typeof source === 'string') {
41
+ return { provider: null, problem: source };
49
42
  }
50
43
 
51
- const provider = new ImpProvider(new ImpClientPort({ url, token }), {
44
+ const provider = new ImpProvider(new ImpClientPort({ url, readToken: source.readToken }), {
52
45
  ...pickString(options, 'image'),
53
46
  ...pickString(options, 'guestDir'),
54
47
  ...pickString(options, 'guestATC'),
@@ -58,6 +51,60 @@ export function buildImpProvider(
58
51
  return { provider, problem: null };
59
52
  }
60
53
 
54
+ // How the port reads the token: a function, so a file token rereads.
55
+ interface TokenSource {
56
+ readonly readToken: () => string | null;
57
+ }
58
+
59
+ // The token source the options give, or the problem that keeps the target
60
+ // from one. A problem holds a variable's name or a file's path, never a
61
+ // token.
62
+ function loadTokenSource(
63
+ id: string,
64
+ options: Readonly<Record<string, unknown>>,
65
+ env: Readonly<Record<string, string | undefined>>,
66
+ ): TokenSource | string {
67
+ const tokenEnv = options['tokenEnv'];
68
+ const tokenFile = options['tokenFile'];
69
+ const target = JSON.stringify(id);
70
+
71
+ if (tokenEnv !== undefined && tokenFile !== undefined) {
72
+ return `target ${target} must give its impd token through tokenEnv or tokenFile, not both`;
73
+ }
74
+
75
+ if (tokenFile !== undefined) {
76
+ if (typeof tokenFile !== 'string' || tokenFile === '') {
77
+ return `target ${target} must give tokenFile as a non-empty string`;
78
+ }
79
+
80
+ try {
81
+ readImpToken(tokenFile);
82
+ } catch (error) {
83
+ const detail = error instanceof Error ? error.message : String(error);
84
+
85
+ return `target ${target} reads its impd token from a file it cannot use: ${detail}`;
86
+ }
87
+
88
+ return { readToken: () => readImpToken(tokenFile) };
89
+ }
90
+
91
+ if (tokenEnv === undefined) {
92
+ return { readToken: () => null };
93
+ }
94
+
95
+ if (typeof tokenEnv !== 'string' || tokenEnv === '') {
96
+ return `target ${target} must give tokenEnv as a non-empty string`;
97
+ }
98
+
99
+ const token = env[tokenEnv];
100
+
101
+ if (token === undefined || token === '') {
102
+ return `target ${target} reads its impd token from ${tokenEnv}, which is unset or empty in the daemon's environment`;
103
+ }
104
+
105
+ return { readToken: () => token };
106
+ }
107
+
61
108
  function pickString(
62
109
  options: Readonly<Record<string, unknown>>,
63
110
  key: 'image' | 'guestDir' | 'guestATC',
@@ -5,8 +5,9 @@ import type { TrailEntry } from '../store/trail-entry';
5
5
  import type { NoteReport } from './parse-report';
6
6
 
7
7
  /**
8
- * The trail entry for one note a session reported, carrying its label, a
9
- * preview of its text, and the id its reporter gave it, when it gave one.
8
+ * The trail entry for one note a session reported, carrying its label, its
9
+ * text and a preview of it, and the id its reporter gave it, when it gave
10
+ * one.
10
11
  */
11
12
  export function buildReportTrailEntry(
12
13
  sessionID: SessionID,
@@ -22,6 +23,7 @@ export function buildReportTrailEntry(
22
23
  kind: 'report',
23
24
  label: report.label,
24
25
  detail: truncateDetail(report.text),
26
+ text: report.text,
25
27
  ...(reportID === undefined ? {} : { reportID }),
26
28
  };
27
29
  }
@@ -0,0 +1,41 @@
1
+ import { encodeCursor } from '../protocol/encode-cursor';
2
+ import type { StoredReport } from '../store/state-store';
3
+ import type { SessionDescriptor } from './sessions';
4
+
5
+ /**
6
+ * One report as `report.get` returns it: the cursor of its event, when it
7
+ * arrived, the session that sent it and that session's name, its label, its
8
+ * text, and whether that text is whole.
9
+ */
10
+ export interface ReportView {
11
+ readonly report: string;
12
+ readonly at: number;
13
+ readonly session: string;
14
+ readonly name: string | null;
15
+ readonly label: string;
16
+ readonly text: string;
17
+ readonly complete: boolean;
18
+ }
19
+
20
+ export function buildReportView(
21
+ stored: StoredReport,
22
+ sessions: readonly SessionDescriptor[],
23
+ ): ReportView {
24
+ // A row written before atc session ids stayed stable across restores
25
+ // carries an earlier atc id, so the agent session id links it.
26
+ const live =
27
+ (stored.agentSessionID === null
28
+ ? undefined
29
+ : sessions.find((s) => s.agentSessionID === stored.agentSessionID)) ??
30
+ sessions.find((s) => s.id === stored.atcID);
31
+
32
+ return {
33
+ report: encodeCursor({ kind: 'events', id: stored.id }),
34
+ at: stored.at,
35
+ session: live?.id ?? stored.atcID,
36
+ name: live?.name ?? null,
37
+ label: stored.label,
38
+ text: stored.text,
39
+ complete: stored.complete,
40
+ };
41
+ }
@@ -203,6 +203,11 @@ export function buildScopedContext(
203
203
 
204
204
  return ctx.readEvents(afterID, limit, waitMs, sessionID, merged);
205
205
  },
206
+ readReport: (id, outer) => {
207
+ const merged = outer === null ? access : outer.merge(access);
208
+
209
+ return ctx.readReport(id, merged);
210
+ },
206
211
  writeSessionMessage: (sessionID, from, text, keyed) =>
207
212
  canSee(sessionID)
208
213
  ? ctx.writeSessionMessage(sessionID, from, text, buildPrincipalKey(keyed))
@@ -4,6 +4,7 @@ import { DaemonError } from '../protocol/daemon-error';
4
4
  import { DAEMON_FEATURES } from '../protocol/daemon-features';
5
5
  import { decodeCursor } from '../protocol/decode-cursor';
6
6
  import { encodeCursor } from '../protocol/encode-cursor';
7
+ import { LineDecoder } from '../protocol/line-decoder';
7
8
  import { OutboundQueue } from '../protocol/outbound-queue';
8
9
  import type { SocketWriter } from '../protocol/outbound-queue';
9
10
  import { parseRequestParams } from '../protocol/parse-request-params';
@@ -28,6 +29,7 @@ import type { Dims } from './attach-registry';
28
29
  import type { AgentEntry } from './build-agent-list';
29
30
  import type { FleetEvent } from './build-fleet-events';
30
31
  import { buildPayloadHash } from './build-payload-hash';
32
+ import type { ReportView } from './build-report-view';
31
33
  import { buildScopedContext } from './build-scoped-context';
32
34
  import type { TargetEntry } from './build-target-list';
33
35
  import type { KeyedRequest } from './idempotency-ledger';
@@ -189,6 +191,11 @@ export interface DaemonContext {
189
191
  access: TargetAccess | null,
190
192
  ) => Promise<EventsPage>;
191
193
 
194
+ // One report by the trail id of its event, or null for a trail id that
195
+ // holds no report, or whose report's session is outside the access when
196
+ // there is one.
197
+ readonly readReport: (id: number, access: TargetAccess | null) => Promise<ReportView | null>;
198
+
192
199
  // Answers with the `session.message` ok payload, which a keyed retry
193
200
  // replays with the message's current status, or with the refusal.
194
201
  readonly writeSessionMessage: (
@@ -272,9 +279,7 @@ export class DaemonConnection {
272
279
 
273
280
  private readonly queue: OutboundQueue;
274
281
 
275
- private buffer = '';
276
-
277
- private readonly decoder = new TextDecoder();
282
+ private readonly lines = new LineDecoder();
278
283
 
279
284
  private helloed = false;
280
285
 
@@ -346,28 +351,18 @@ export class DaemonConnection {
346
351
  // between two reads decodes whole.
347
352
  // oxlint-disable-next-line prefer-readonly-parameter-types -- a socket read buffer has no readonly form
348
353
  decodeChunk(buf: Uint8Array): string {
349
- return this.decoder.decode(buf, { stream: true });
354
+ return this.lines.decodeText(buf);
350
355
  }
351
356
 
352
357
  applyChunk(chunk: string): void {
353
- const buffered = this.buffer + chunk;
354
-
355
- if (buffered.length > MAX_LINE) {
358
+ if (this.lines.pendingLength + chunk.length > MAX_LINE) {
356
359
  this.sendErr(0, 'bad_args', `line exceeds ${MAX_LINE} bytes`);
357
360
  this.peer.end();
358
361
 
359
362
  return;
360
363
  }
361
364
 
362
- const lines = buffered.split('\n');
363
-
364
- this.buffer = lines.pop() ?? '';
365
-
366
- for (const line of lines) {
367
- if (line.trim() === '') {
368
- continue;
369
- }
370
-
365
+ for (const line of this.lines.splitText(chunk)) {
371
366
  if (!this.applyLine(line)) {
372
367
  this.peer.end();
373
368
 
@@ -799,6 +794,11 @@ export class DaemonConnection {
799
794
 
800
795
  return;
801
796
  }
797
+ case 'report.get': {
798
+ await this.applyReportGet(req, ctx);
799
+
800
+ return;
801
+ }
802
802
  default: {
803
803
  this.sendErr(req.id, 'unknown_method', `unknown method '${req.m}'`);
804
804
  }
@@ -1150,6 +1150,32 @@ export class DaemonConnection {
1150
1150
  });
1151
1151
  }
1152
1152
 
1153
+ // A cursor that is not an events cursor, one at a row that holds no
1154
+ // report, and one at a report outside the access all get one refusal, so
1155
+ // the refusal is the same for a report out of reach and a missing one.
1156
+ private async applyReportGet(req: RequestMsg, ctx: DaemonContext): Promise<void> {
1157
+ const parsed = parseRequestParams('report.get', req.p);
1158
+
1159
+ if (!parsed.ok) {
1160
+ this.sendErr(req.id, 'bad_args', parsed.message);
1161
+
1162
+ return;
1163
+ }
1164
+
1165
+ const decoded = decodeCursor(parsed.data.report);
1166
+
1167
+ const view =
1168
+ decoded === null || decoded.kind !== 'events' ? null : await ctx.readReport(decoded.id, null);
1169
+
1170
+ if (view === null) {
1171
+ this.sendErr(req.id, 'bad_args', `no report '${parsed.data.report}'`);
1172
+
1173
+ return;
1174
+ }
1175
+
1176
+ this.sendOk(req.id, { ...view });
1177
+ }
1178
+
1153
1179
  private async applySessionMessage(req: RequestMsg, ctx: DaemonContext): Promise<void> {
1154
1180
  const parsed = parseRequestParams('session.message', req.p);
1155
1181
 
@@ -1,6 +1,6 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { unlinkSync, writeFileSync } from 'node:fs';
3
- import { hostname } from 'node:os';
3
+ import { hostname, tmpdir } from 'node:os';
4
4
  import { dirname, join } from 'node:path';
5
5
  import type { AdapterEvent, AgentAdapter } from '../agents/agent-adapter';
6
6
  import { planTypedLineInput } from '../agents/plan-typed-line-input';
@@ -32,6 +32,7 @@ import type { ExecutionTarget } from './build-execution-targets';
32
32
  import { buildFleetEvents } from './build-fleet-events';
33
33
  import { buildMessageTrailEntry } from './build-message-trail-entry';
34
34
  import { buildReportTrailEntry } from './build-report-trail-entry';
35
+ import { buildReportView } from './build-report-view';
35
36
  import { buildSessionEvent } from './build-session-event';
36
37
  import { buildSessionMessageEvent } from './build-session-message-event';
37
38
  import { buildSessionReportEvent } from './build-session-report-event';
@@ -907,6 +908,7 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
907
908
  log: (line) => {
908
909
  mgr.log(line);
909
910
  },
911
+ stagingRoot: tmpdir(),
910
912
  },
911
913
  );
912
914
  };
@@ -1568,6 +1570,11 @@ export async function startDaemon(opts: DaemonOptions): Promise<DaemonHandle> {
1568
1570
  await eventSignal.waitForNext(generation, remaining);
1569
1571
  }
1570
1572
  },
1573
+ readReport: async (id, access) => {
1574
+ const stored = await store.findReport(id, buildEventScope(null, access));
1575
+
1576
+ return stored === null ? null : buildReportView(stored, mgr.collectDescriptors());
1577
+ },
1571
1578
  writeSessionMessage: async (sessionID, from, text, keyed) => {
1572
1579
  if (keyed === null) {
1573
1580
  const refusal = findMessageRefusal(sessionID);