@zgeoff/atc 2.13.0 → 2.14.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 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.14.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 {
@@ -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
 
@@ -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))
@@ -28,6 +28,7 @@ import type { Dims } from './attach-registry';
28
28
  import type { AgentEntry } from './build-agent-list';
29
29
  import type { FleetEvent } from './build-fleet-events';
30
30
  import { buildPayloadHash } from './build-payload-hash';
31
+ import type { ReportView } from './build-report-view';
31
32
  import { buildScopedContext } from './build-scoped-context';
32
33
  import type { TargetEntry } from './build-target-list';
33
34
  import type { KeyedRequest } from './idempotency-ledger';
@@ -189,6 +190,11 @@ export interface DaemonContext {
189
190
  access: TargetAccess | null,
190
191
  ) => Promise<EventsPage>;
191
192
 
193
+ // One report by the trail id of its event, or null for a trail id that
194
+ // holds no report, or whose report's session is outside the access when
195
+ // there is one.
196
+ readonly readReport: (id: number, access: TargetAccess | null) => Promise<ReportView | null>;
197
+
192
198
  // Answers with the `session.message` ok payload, which a keyed retry
193
199
  // replays with the message's current status, or with the refusal.
194
200
  readonly writeSessionMessage: (
@@ -799,6 +805,11 @@ export class DaemonConnection {
799
805
 
800
806
  return;
801
807
  }
808
+ case 'report.get': {
809
+ await this.applyReportGet(req, ctx);
810
+
811
+ return;
812
+ }
802
813
  default: {
803
814
  this.sendErr(req.id, 'unknown_method', `unknown method '${req.m}'`);
804
815
  }
@@ -1150,6 +1161,32 @@ export class DaemonConnection {
1150
1161
  });
1151
1162
  }
1152
1163
 
1164
+ // A cursor that is not an events cursor, one at a row that holds no
1165
+ // report, and one at a report outside the access all get one refusal, so
1166
+ // the refusal is the same for a report out of reach and a missing one.
1167
+ private async applyReportGet(req: RequestMsg, ctx: DaemonContext): Promise<void> {
1168
+ const parsed = parseRequestParams('report.get', req.p);
1169
+
1170
+ if (!parsed.ok) {
1171
+ this.sendErr(req.id, 'bad_args', parsed.message);
1172
+
1173
+ return;
1174
+ }
1175
+
1176
+ const decoded = decodeCursor(parsed.data.report);
1177
+
1178
+ const view =
1179
+ decoded === null || decoded.kind !== 'events' ? null : await ctx.readReport(decoded.id, null);
1180
+
1181
+ if (view === null) {
1182
+ this.sendErr(req.id, 'bad_args', `no report '${parsed.data.report}'`);
1183
+
1184
+ return;
1185
+ }
1186
+
1187
+ this.sendOk(req.id, { ...view });
1188
+ }
1189
+
1153
1190
  private async applySessionMessage(req: RequestMsg, ctx: DaemonContext): Promise<void> {
1154
1191
  const parsed = parseRequestParams('session.message', req.p);
1155
1192
 
@@ -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);
@@ -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);
@@ -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
 
@@ -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];
@@ -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' }),
@@ -48,6 +48,10 @@ interface EventsTable {
48
48
  // The id a remote session's reporter gave a report row, unique so a
49
49
  // resent report lands once; null on every other row.
50
50
  report_id: string | null;
51
+
52
+ // A report row's whole text, which detail previews; null on every other
53
+ // row, and on a report row written before the column existed.
54
+ report_text: string | null;
51
55
  }
52
56
 
53
57
  interface SpawnHistoryTable {
@@ -437,6 +441,11 @@ const MIGRATIONS: Record<string, Migration> = {
437
441
  .execute();
438
442
  },
439
443
  },
444
+ '023_add_events_report_text': {
445
+ async up(db: Kysely<StateStoreSchema>) {
446
+ await db.schema.alterTable('events').addColumn('report_text', 'text').execute();
447
+ },
448
+ },
440
449
  };
441
450
 
442
451
  const PROVIDER: MigrationProvider = {
@@ -63,6 +63,23 @@ export interface StoredEvent {
63
63
  readonly label?: string;
64
64
  }
65
65
 
66
+ /**
67
+ * One report as the trail holds it. A report recorded before the trail kept
68
+ * whole texts has only its preview, so its text is that preview and
69
+ * `complete` is false.
70
+ */
71
+ export interface StoredReport {
72
+ readonly id: number;
73
+
74
+ // Epoch ms the report arrived.
75
+ readonly at: number;
76
+ readonly atcID: SessionID;
77
+ readonly agentSessionID: AgentSessionID | null;
78
+ readonly label: string;
79
+ readonly text: string;
80
+ readonly complete: boolean;
81
+ }
82
+
66
83
  /**
67
84
  * Daemon state in one SQLite store: the restorable fleet, the event trail
68
85
  * (hook events, message status changes, and reports) that events.read and
@@ -347,6 +364,7 @@ export class StateStore {
347
364
  kind: entry.kind,
348
365
  detail: entry.detail,
349
366
  report_id: entry.kind === 'report' ? (entry.reportID ?? null) : null,
367
+ report_text: entry.kind === 'report' ? entry.text : null,
350
368
  })
351
369
  .onConflict((oc) => oc.column('report_id').doNothing())
352
370
  .executeTakeFirst();
@@ -372,6 +390,32 @@ export class StateStore {
372
390
  return buildStoredEvents(rows);
373
391
  }
374
392
 
393
+ // A row that is not a report, or that lies outside the scope, misses as a
394
+ // row the trail never held does.
395
+ async findReport(id: number, scope: EventScope | null = null): Promise<StoredReport | null> {
396
+ const row = await this.db
397
+ .selectFrom('events')
398
+ .select(['id', 'ts', 'atc_id', 'session_id', 'message', 'detail', 'report_text'])
399
+ .where('id', '=', id)
400
+ .where('kind', '=', 'report')
401
+ .where((eb) => buildScopeMatch(eb, scope))
402
+ .executeTakeFirst();
403
+
404
+ if (row === undefined) {
405
+ return null;
406
+ }
407
+
408
+ return {
409
+ id: row.id,
410
+ at: Date.parse(row.ts),
411
+ atcID: toSessionID(row.atc_id),
412
+ agentSessionID: row.session_id === null ? null : toAgentSessionID(row.session_id),
413
+ label: row.message ?? '',
414
+ text: row.report_text ?? row.detail ?? '',
415
+ complete: row.report_text !== null,
416
+ };
417
+ }
418
+
375
419
  async collectLatestEvents(
376
420
  limit: number,
377
421
  scope: EventScope | null = null,
@@ -19,6 +19,9 @@ interface ReportTrailEntry extends TrailEntryBase {
19
19
  readonly kind: 'report';
20
20
  readonly label: string;
21
21
 
22
+ // The report's whole text, which the detail previews.
23
+ readonly text: string;
24
+
22
25
  // The id a remote session's reporter gave the report, so a resent
23
26
  // report is stored once; absent for a report that carries none.
24
27
  readonly reportID?: string;