@zgeoff/atc 2.29.0 → 2.30.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zgeoff/atc",
3
- "version": "2.29.0",
3
+ "version": "2.30.1",
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",
@@ -56,7 +56,7 @@ export class LocalPTYProvider implements ExecutionProvider {
56
56
  cols: spec.cols,
57
57
  rows: spec.rows,
58
58
  cwd: spec.cwd,
59
- env: collectCleanEnv(spec.env, spec.withheldEnv),
59
+ env: buildPTYEnv(spec),
60
60
  });
61
61
 
62
62
  const subscriptions = new Set<{ readonly dispose: () => void }>();
@@ -181,6 +181,23 @@ export class LocalPTYProvider implements ExecutionProvider {
181
181
  }
182
182
  }
183
183
 
184
+ const USABLE_TERM = 'xterm-256color';
185
+
186
+ // A pseudo-terminal always has a terminal on its far side, so a harness never
187
+ // starts with an empty or dumb TERM, which leaves an agent CLI drawing with no
188
+ // colour. TERM is always passed: the child starts from the environment the
189
+ // daemon itself started with, and the keys passed here only add to it or
190
+ // override it, so leaving TERM out hands the child the daemon's own value.
191
+ function buildPTYEnv(spec: HarnessSpec): Record<string, string> {
192
+ const env = collectCleanEnv(spec.env, spec.withheldEnv);
193
+ const term = env['TERM'];
194
+
195
+ return {
196
+ ...env,
197
+ TERM: term === undefined || term === '' || term === 'dumb' ? USABLE_TERM : term,
198
+ };
199
+ }
200
+
184
201
  // A process another user owns still runs, so only a missing process counts
185
202
  // as gone.
186
203
  function isProcessRunning(pid: number): boolean {
@@ -119,6 +119,12 @@ const EVENTS_READ_INPUT: Readonly<Record<string, unknown>> = z.toJSONSchema(
119
119
  waitMs: WAIT_MS.describe(
120
120
  'How long to wait for a new event when none is pending, in milliseconds; defaults to 0, capped at 30000. Keep it short.',
121
121
  ),
122
+ reportText: z
123
+ .boolean()
124
+ .optional()
125
+ .describe(
126
+ "true adds each report's whole text to its event, so one call reads every report of the page; defaults to false",
127
+ ),
122
128
  }),
123
129
  { io: 'input' },
124
130
  );
@@ -298,6 +304,10 @@ const EVENTS_OUTPUT: Readonly<Record<string, unknown>> = {
298
304
  detail: { type: ['string', 'null'] },
299
305
  message: { type: 'string' },
300
306
  label: { type: 'string' },
307
+ report: { type: 'string' },
308
+ text: { type: 'string' },
309
+ complete: { type: 'boolean' },
310
+ textError: { type: 'string' },
301
311
  },
302
312
  required: ['cursor', 'at', 'session', 'name', 'kind', 'detail'],
303
313
  },
@@ -524,10 +534,13 @@ export const MCP_TOOLS: readonly MCPToolDefinition[] = [
524
534
  annotations: READ_ONLY,
525
535
  scope: 'read',
526
536
  description:
527
- '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 report handle of that event when it carries one, else its cursor. 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.',
537
+ '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 report handle of that event when it carries one, else its cursor, or pass reportText: true to get the full text of every report in this call. With reportText, each report event also carries text and complete (false when atc kept only the preview), or textError when its text could not be read within 10 seconds; the page holds at most 64 KiB of report text and stops early, with more true, when the next report would not fit or 10 seconds of report reads have passed. 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.',
528
538
  inputSchema: EVENTS_READ_INPUT,
529
539
  outputSchema: EVENTS_OUTPUT,
530
- requires: { output: 'events.more', inputs: { session: 'events.session' } },
540
+ requires: {
541
+ output: 'events.more',
542
+ inputs: { session: 'events.session', reportText: 'report.get' },
543
+ },
531
544
  },
532
545
  {
533
546
  name: 'atc_report_get',
@@ -0,0 +1,127 @@
1
+ import { isRecord } from '../shared/report';
2
+ import type { FleetCaller } from './types';
3
+
4
+ // The most report text one events page carries, in UTF-8 bytes: as much as
5
+ // the daemon keeps of one report, so a page always has room for its first.
6
+ const REPORT_TEXT_BUDGET_BYTES = 65_536;
7
+
8
+ // How long the reads of one page's report texts may take in all. An events
9
+ // read can hold a call for about 35 seconds, and the HTTP server drops a
10
+ // call idle for 60, so the report reads finish well inside what is left.
11
+ const REPORT_READ_DEADLINE_MS = 10_000;
12
+
13
+ // What one report read gives its event: its text and whether that text is
14
+ // whole, or the error the read failed with.
15
+ type ReportRead = Readonly<Record<string, unknown>>;
16
+
17
+ /**
18
+ * An events page with the whole text of each of its reports, read through
19
+ * `report.get` by the page's own caller, so each read rides the reach the
20
+ * page was read under. The reads run one at a time in page order, so one
21
+ * report text at most is ever in flight, under one deadline for them all.
22
+ * A report event gains the `text` and `complete` its read returns, or
23
+ * `textError` when the read fails or outlasts the deadline, and keeps its
24
+ * preview in `detail`. The page stops before the first report whose text
25
+ * would carry it past the budget, or whose read would start after the
26
+ * deadline: it then holds the cursor of the last event it keeps and `more`
27
+ * true, so the next read starts at that report. Every other field of the
28
+ * page, such as a gateway's `unavailable` and `truncated`, passes through
29
+ * unchanged.
30
+ */
31
+ export async function readReportTexts(
32
+ caller: FleetCaller,
33
+ page: Readonly<Record<string, unknown>>,
34
+ deadlineMs: number = REPORT_READ_DEADLINE_MS,
35
+ ): Promise<Readonly<Record<string, unknown>>> {
36
+ const raw: unknown = page['events'];
37
+ const events = Array.isArray(raw) ? raw.filter((event) => isRecord(event)) : [];
38
+ const endsAt = Date.now() + deadlineMs;
39
+ const timeout = Promise.withResolvers<ReportRead>();
40
+
41
+ const timer = setTimeout(() => {
42
+ timeout.resolve({
43
+ textError: `timeout: the report text did not arrive within ${deadlineMs} ms`,
44
+ });
45
+ }, deadlineMs);
46
+
47
+ const read = await readPageReports(caller, page, events, endsAt, timeout.promise);
48
+
49
+ clearTimeout(timer);
50
+
51
+ return read;
52
+ }
53
+
54
+ // The page with its reports read, one at a time, until the budget or the
55
+ // deadline at `endsAt` stops it. A read still out when `timeout` settles
56
+ // gives way to the timeout it settles with.
57
+ async function readPageReports(
58
+ caller: FleetCaller,
59
+ page: Readonly<Record<string, unknown>>,
60
+ events: readonly Readonly<Record<string, unknown>>[],
61
+ endsAt: number,
62
+ timeout: Readonly<Promise<ReportRead>>,
63
+ ): Promise<Readonly<Record<string, unknown>>> {
64
+ const kept: Readonly<Record<string, unknown>>[] = [];
65
+ let used = 0;
66
+
67
+ for (const event of events) {
68
+ if (event['kind'] !== 'report') {
69
+ kept.push(event);
70
+ continue;
71
+ }
72
+
73
+ const last = kept.at(-1);
74
+
75
+ if (last !== undefined && Date.now() >= endsAt) {
76
+ return { ...page, events: kept, cursor: last['cursor'], more: true };
77
+ }
78
+
79
+ const read = await Promise.race([readReportText(caller, event), timeout]);
80
+
81
+ const bytes = typeof read['text'] === 'string' ? Buffer.byteLength(read['text']) : 0;
82
+
83
+ if (last !== undefined && used + bytes > REPORT_TEXT_BUDGET_BYTES) {
84
+ return { ...page, events: kept, cursor: last['cursor'], more: true };
85
+ }
86
+
87
+ used += bytes;
88
+
89
+ kept.push({ ...event, ...read });
90
+ }
91
+
92
+ return { ...page, events: kept };
93
+ }
94
+
95
+ // One report event's whole text and whether it is complete, or the error
96
+ // its read failed with. The read takes the event's report handle, which a
97
+ // gateway adds, else the event's own cursor, which a daemon reads a report
98
+ // by.
99
+ async function readReportText(
100
+ caller: FleetCaller,
101
+ event: Readonly<Record<string, unknown>>,
102
+ ): Promise<ReportRead> {
103
+ const handle = typeof event['report'] === 'string' ? event['report'] : event['cursor'];
104
+
105
+ try {
106
+ const report = await caller.sendRequest('report.get', { report: handle }, ['report.get']);
107
+
108
+ return { text: report['text'], complete: report['complete'] };
109
+ } catch (error) {
110
+ return { textError: formatReadError(error) };
111
+ }
112
+ }
113
+
114
+ // An error with a lowercase protocol-style code reads as `<code>: <message>`,
115
+ // as a failed tool call does.
116
+ function formatReadError(error: unknown): string {
117
+ if (
118
+ error instanceof Error &&
119
+ 'code' in error &&
120
+ typeof error.code === 'string' &&
121
+ /^[a-z][a-z_]*$/.test(error.code)
122
+ ) {
123
+ return `${error.code}: ${error.message}`;
124
+ }
125
+
126
+ return error instanceof Error ? error.message : String(error);
127
+ }
@@ -4,6 +4,7 @@ import { DaemonError } from '../protocol/daemon-error';
4
4
  import type { DaemonFeature } from '../protocol/daemon-features';
5
5
  import { isRecord } from '../shared/report';
6
6
  import { parseIdempotencyKey } from './parse-idempotency-key';
7
+ import { readReportTexts } from './read-report-texts';
7
8
  import type { FleetCaller, ToolContext } from './types';
8
9
 
9
10
  /**
@@ -196,6 +197,14 @@ export function runTool(
196
197
  required,
197
198
  );
198
199
 
200
+ // Each report's whole text rides the same call, so a reader catches
201
+ // up without one report read per report.
202
+ if (args['reportText'] === true) {
203
+ const withTexts = await readReportTexts(caller, ok);
204
+
205
+ return buildObjectResult(withTexts);
206
+ }
207
+
199
208
  return buildObjectResult(ok);
200
209
  })
201
210
  .with('atc_report_get', async () => {