@zgeoff/atc 2.10.3 → 2.11.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.
@@ -1,18 +1,34 @@
1
1
  import { match } from 'ts-pattern';
2
2
  import { DaemonError } from '../protocol/daemon-error';
3
+ import type { DaemonFeature } from '../protocol/daemon-features';
4
+ import { isRecord } from '../shared/report';
3
5
  import type { FleetCaller, ToolContext } from './types';
4
6
 
7
+ /**
8
+ * A tool's result: the text every client reads, and for a tool whose result
9
+ * is data, that data as an object for clients that read structured content.
10
+ */
11
+ interface ToolResult {
12
+ readonly text: string;
13
+ readonly structured: Readonly<Record<string, unknown>> | null;
14
+ }
15
+
5
16
  export function runTool(
6
17
  caller: FleetCaller,
7
18
  name: string,
8
19
  args: Readonly<Record<string, unknown>>,
9
20
  ctx: ToolContext,
10
- ): Promise<string> {
21
+ ): Promise<ToolResult> {
11
22
  return match(name)
12
23
  .with('atc_session_list', async () => {
13
24
  const ok = await caller.sendRequest('session.list');
14
25
 
15
- return JSON.stringify(ok['sessions'], null, 2);
26
+ // The text stays the bare list older clients read; structured content
27
+ // has to be an object.
28
+ return {
29
+ text: JSON.stringify(ok['sessions'], null, 2),
30
+ structured: { sessions: ok['sessions'] },
31
+ };
16
32
  })
17
33
  .with('atc_session_spawn', async () => {
18
34
  const rawAgent = args['agent'];
@@ -32,7 +48,7 @@ export function runTool(
32
48
  ? await sendNestedSpawn(caller, params, ctx.callerSessionID)
33
49
  : await caller.sendRequest('session.spawn', params);
34
50
 
35
- return JSON.stringify(ok['session'], null, 2);
51
+ return buildObjectResult(ok['session']);
36
52
  })
37
53
  .with('atc_session_input', async () => {
38
54
  await caller.sendRequest('session.input', {
@@ -40,12 +56,15 @@ export function runTool(
40
56
  d: `${typeof args['text'] === 'string' ? args['text'] : ''}\n`,
41
57
  });
42
58
 
43
- return 'sent';
59
+ return { text: 'sent', structured: null };
44
60
  })
45
61
  .with('atc_session_screen', async () => {
46
62
  const ok = await caller.sendRequest('session.screen', { session: args['session'] });
47
63
 
48
- return typeof ok['text'] === 'string' ? ok['text'] : JSON.stringify(ok);
64
+ return {
65
+ text: typeof ok['text'] === 'string' ? ok['text'] : JSON.stringify(ok),
66
+ structured: null,
67
+ };
49
68
  })
50
69
  .with('atc_session_update', async () => {
51
70
  await caller.sendRequest('session.update', {
@@ -54,32 +73,40 @@ export function runTool(
54
73
  ...(typeof args['pinned'] === 'boolean' ? { pinned: args['pinned'] } : {}),
55
74
  });
56
75
 
57
- return 'updated';
76
+ return { text: 'updated', structured: null };
58
77
  })
59
78
  .with('atc_session_kill', async () => {
60
79
  await caller.sendRequest('session.kill', { session: args['session'] });
61
80
 
62
- return 'killed';
81
+ return { text: 'killed', structured: null };
63
82
  })
64
83
  .with('atc_session_ack', async () => {
65
84
  await caller.sendRequest('session.ack', { session: args['session'] });
66
85
 
67
- return 'acked';
86
+ return { text: 'acked', structured: null };
68
87
  })
69
88
  .with('atc_resume_command', async () => {
70
89
  const ok = await caller.sendRequest('session.resumeCommand', { session: args['session'] });
71
90
 
72
- return typeof ok['command'] === 'string' ? ok['command'] : JSON.stringify(ok);
91
+ return {
92
+ text: typeof ok['command'] === 'string' ? ok['command'] : JSON.stringify(ok),
93
+ structured: null,
94
+ };
73
95
  })
74
96
  .with('atc_dirs_list', async () => {
75
97
  const ok = await caller.sendRequest('dirs.list');
76
98
 
77
- return JSON.stringify(ok['dirs'], null, 2);
99
+ return { text: JSON.stringify(ok['dirs'], null, 2), structured: { dirs: ok['dirs'] } };
100
+ })
101
+ .with('atc_agents_list', async () => {
102
+ const ok = await caller.sendRequest('agents.list', {}, ['agents.list']);
103
+
104
+ return buildObjectResult(ok);
78
105
  })
79
106
  .with('atc_session_get', async () => {
80
107
  const ok = await caller.sendRequest('session.get', { session: args['session'] });
81
108
 
82
- return JSON.stringify(ok, null, 2);
109
+ return buildObjectResult(ok);
83
110
  })
84
111
  .with('atc_session_read', async () => {
85
112
  const ok = await caller.sendRequest('session.read', {
@@ -88,16 +115,24 @@ export function runTool(
88
115
  ...(typeof args['limit'] === 'number' ? { limit: args['limit'] } : {}),
89
116
  });
90
117
 
91
- return JSON.stringify(ok, null, 2);
118
+ return buildObjectResult(ok);
92
119
  })
93
120
  .with('atc_events_read', async () => {
94
- const ok = await caller.sendRequest('events.read', {
95
- ...(typeof args['cursor'] === 'string' ? { cursor: args['cursor'] } : {}),
96
- ...(typeof args['limit'] === 'number' ? { limit: args['limit'] } : {}),
97
- ...(typeof args['waitMs'] === 'number' ? { waitMs: args['waitMs'] } : {}),
98
- });
99
-
100
- return JSON.stringify(ok, null, 2);
121
+ const filtered = typeof args['session'] === 'string' && args['session'] !== '';
122
+ const required: DaemonFeature[] = filtered ? ['events.session'] : [];
123
+
124
+ const ok = await caller.sendRequest(
125
+ 'events.read',
126
+ {
127
+ ...(typeof args['cursor'] === 'string' ? { cursor: args['cursor'] } : {}),
128
+ ...(typeof args['limit'] === 'number' ? { limit: args['limit'] } : {}),
129
+ ...(typeof args['waitMs'] === 'number' ? { waitMs: args['waitMs'] } : {}),
130
+ ...(typeof args['session'] === 'string' ? { session: args['session'] } : {}),
131
+ },
132
+ required,
133
+ );
134
+
135
+ return buildObjectResult(ok);
101
136
  })
102
137
  .with('atc_session_message', async () => {
103
138
  const given = args['from'];
@@ -113,16 +148,33 @@ export function runTool(
113
148
  from,
114
149
  });
115
150
 
116
- return JSON.stringify(ok, null, 2);
151
+ return buildObjectResult(ok);
117
152
  })
118
153
  .with('atc_message_get', async () => {
119
- const ok = await caller.sendRequest('message.get', { message: args['message'] });
120
-
121
- return JSON.stringify(ok, null, 2);
154
+ const waits = typeof args['waitMs'] === 'number' && args['waitMs'] > 0;
155
+ const required: DaemonFeature[] = waits ? ['message.wait'] : [];
156
+
157
+ const ok = await caller.sendRequest(
158
+ 'message.get',
159
+ {
160
+ message: args['message'],
161
+ ...(typeof args['waitMs'] === 'number' ? { waitMs: args['waitMs'] } : {}),
162
+ },
163
+ required,
164
+ );
165
+
166
+ return buildObjectResult(ok);
122
167
  })
123
168
  .otherwise(() => Promise.reject(new Error(`unknown tool '${name}'`)));
124
169
  }
125
170
 
171
+ function buildObjectResult(value: unknown): ToolResult {
172
+ return {
173
+ text: JSON.stringify(value, null, 2),
174
+ structured: isRecord(value) ? value : null,
175
+ };
176
+ }
177
+
126
178
  // The inherited id can point at a session another daemon hosts, or one
127
179
  // this daemon no longer lists; the spawn then lands top-level instead of
128
180
  // failing the tool call.
@@ -30,6 +30,9 @@ interface MCPHTTPServerOptions {
30
30
  readonly dbPath: string;
31
31
  readonly printApproval: (line: string) => void;
32
32
 
33
+ // Receives one line per request the server answers.
34
+ readonly printRequest: (line: string) => void;
35
+
33
36
  // How long a rotated refresh token still answers with its successor.
34
37
  readonly refreshReuseSeconds?: number;
35
38
  }
@@ -38,19 +41,24 @@ interface MCPHTTPServerOptions {
38
41
  * A running MCP HTTP server.
39
42
  */
40
43
  export interface MCPHTTPServer {
41
- // The local address the server listens on.
44
+ // The loopback address the server answers on, for clients on this machine.
42
45
  readonly url: string;
43
46
 
47
+ // The address the server is bound to.
48
+ readonly listening: string;
49
+
44
50
  // The public origin: the OAuth issuer.
45
51
  readonly origin: string;
46
52
  readonly stop: () => Promise<void>;
47
53
  }
48
54
 
49
55
  /**
50
- * Serves atc's MCP tools over streamable HTTP at `/mcp`, with better-auth as
51
- * the OAuth 2.1 authorization server in the same process. Only the routes a
52
- * connector and the operator's browser need reach better-auth; every other
53
- * path is a 404. A request whose Host header is not the server's own is
56
+ * Serves atc's MCP tools over streamable HTTP at `/mcp` and `/`, with
57
+ * better-auth as the OAuth 2.1 authorization server in the same process. Only
58
+ * the routes a connector and the operator's browser need reach better-auth;
59
+ * every other path is a 404. Each answered request prints one line with its
60
+ * method, path, JSON-RPC method and tool, status, duration, and MCP protocol
61
+ * version, and never a body, query, credential, or address. A request whose Host header is not the server's own is
54
62
  * refused, so a DNS rebinding page cannot reach it through a browser, and a
55
63
  * browser form post from any other origin is refused too.
56
64
  */
@@ -81,23 +89,33 @@ export async function startMCPHTTPServer(options: MCPHTTPServerOptions): Promise
81
89
  idleTimeout: 60,
82
90
  maxRequestBodySize: MAX_LINE,
83
91
  fetch: async (request, bunServer) => {
92
+ const startedAt = performance.now();
84
93
  const state = holder.ready;
94
+ let rpc: RPCLabel | null = null;
95
+ let response: Response;
85
96
 
86
97
  if (state === null) {
87
- return new Response(null, { status: 503 });
98
+ response = new Response(null, { status: 503 });
99
+ } else {
100
+ try {
101
+ response = await answerHTTPRequest(
102
+ state,
103
+ request,
104
+ bunServer.requestIP(request)?.address ?? null,
105
+ (label) => {
106
+ rpc = label;
107
+ },
108
+ );
109
+ } catch {
110
+ response = new Response(null, { status: 503 });
111
+ }
88
112
  }
89
113
 
90
- try {
91
- const answered = await answerHTTPRequest(
92
- state,
93
- request,
94
- bunServer.requestIP(request)?.address ?? null,
95
- );
114
+ options.printRequest(
115
+ formatRequestLine(request, rpc, response.status, performance.now() - startedAt),
116
+ );
96
117
 
97
- return answered;
98
- } catch {
99
- return new Response(null, { status: 503 });
100
- }
118
+ return response;
101
119
  },
102
120
  });
103
121
 
@@ -141,6 +159,7 @@ export async function startMCPHTTPServer(options: MCPHTTPServerOptions): Promise
141
159
 
142
160
  return {
143
161
  url: local,
162
+ listening: formatBindURL(options.host, port),
144
163
  origin,
145
164
  stop: async () => {
146
165
  await server.stop(true);
@@ -149,25 +168,101 @@ export async function startMCPHTTPServer(options: MCPHTTPServerOptions): Promise
149
168
  };
150
169
  }
151
170
 
171
+ // An IPv6 address takes the brackets a URL puts around it.
172
+ function formatBindURL(host: string, port: number): string {
173
+ const bracketed = host.includes(':') && !host.startsWith('[') ? `[${host}]` : host;
174
+
175
+ return `http://${bracketed}:${port}`;
176
+ }
177
+
152
178
  interface ServerState {
153
179
  readonly hosts: ReadonlySet<string>;
154
180
  readonly origins: ReadonlySet<string>;
155
181
  readonly ctx: HTTPServerContext;
156
182
  }
157
183
 
184
+ // MCP answers at `/` as well as at `/mcp`, for a client configured with the
185
+ // bare origin. Both are the one resource `<origin>/mcp`.
186
+ const MCP_PATHS: ReadonlySet<string> = new Set(['/mcp', '/']);
187
+
188
+ // The JSON-RPC method and tool name of an MCP request, for its request line.
189
+ interface RPCLabel {
190
+ readonly method: string;
191
+ readonly tool: string | null;
192
+ }
193
+
194
+ function findRPCLabel(body: string): RPCLabel | null {
195
+ let message: unknown;
196
+
197
+ try {
198
+ message = JSON.parse(body);
199
+ } catch {
200
+ return null;
201
+ }
202
+
203
+ if (!isRecord(message) || typeof message['method'] !== 'string') {
204
+ return null;
205
+ }
206
+
207
+ const params = message['params'];
208
+
209
+ return {
210
+ method: message['method'],
211
+ tool: isRecord(params) && typeof params['name'] === 'string' ? params['name'] : null,
212
+ };
213
+ }
214
+
215
+ // Client-sent values in a request line are cut to this many characters.
216
+ const LABEL_LENGTH = 64;
217
+
218
+ // The line names the path without its query, which carries authorization
219
+ // codes and signed state.
220
+ function formatRequestLine(
221
+ request: Request,
222
+ rpc: RPCLabel | null,
223
+ status: number,
224
+ durationMs: number,
225
+ ): string {
226
+ const path = new URL(request.url).pathname;
227
+
228
+ const version = request.headers.get('mcp-protocol-version');
229
+
230
+ const fields = [
231
+ request.method,
232
+ toLogText(path, 128),
233
+ String(status),
234
+ `${Math.round(durationMs)}ms`,
235
+ ...(rpc === null ? [] : [`rpc=${toLogText(rpc.method, LABEL_LENGTH)}`]),
236
+ ...(rpc === null || rpc.tool === null ? [] : [`tool=${toLogText(rpc.tool, LABEL_LENGTH)}`]),
237
+ ...(version === null ? [] : [`mcp-protocol-version=${toLogText(version, LABEL_LENGTH)}`]),
238
+ ];
239
+
240
+ return fields.join(' ');
241
+ }
242
+
243
+ // Control and format characters are dropped, so a client cannot write
244
+ // escape sequences into the operator's terminal.
245
+ function toLogText(value: string, length: number): string {
246
+ return value.replaceAll(/[\p{Cc}\p{Cf}\s]/gu, '').slice(0, length);
247
+ }
248
+
158
249
  // The better-auth routes a connector calls directly.
159
- const PASSED_THROUGH: ReadonlySet<string> = new Set([
250
+ const PASSED_THROUGH: ReadonlySet<string> = new Set(['POST /oauth2/revoke']);
251
+
252
+ // Both paths serve the protected resource metadata for `<origin>/mcp`.
253
+ const RESOURCE_METADATA: ReadonlySet<string> = new Set([
160
254
  'GET /.well-known/oauth-protected-resource',
161
255
  'GET /.well-known/oauth-protected-resource/mcp',
162
- 'POST /oauth2/revoke',
163
256
  ]);
164
257
 
165
258
  // `socketAddress` is the peer the request arrived from: the requester, or the
166
- // proxy or tunnel in front of atc.
259
+ // proxy or tunnel in front of atc. `onRPC` receives an accepted MCP request's
260
+ // JSON-RPC method and tool, for its request line.
167
261
  async function answerHTTPRequest(
168
262
  state: ServerState,
169
263
  request: Request,
170
264
  socketAddress: string | null,
265
+ onRPC: (label: RPCLabel | null) => void,
171
266
  ): Promise<Response> {
172
267
  const host = request.headers.get('host');
173
268
 
@@ -187,7 +282,7 @@ async function answerHTTPRequest(
187
282
  return ctx.store.auth.handler(toPublicRequest(ctx, request, url));
188
283
  }
189
284
 
190
- if (route === 'GET /.well-known/oauth-authorization-server') {
285
+ if (route === 'GET /.well-known/oauth-authorization-server' || RESOURCE_METADATA.has(route)) {
191
286
  return answerMetadataRequest(ctx, request, url);
192
287
  }
193
288
 
@@ -226,7 +321,7 @@ async function answerHTTPRequest(
226
321
  });
227
322
  }
228
323
 
229
- if (url.pathname === '/mcp') {
324
+ if (MCP_PATHS.has(url.pathname)) {
230
325
  if (isForeignOrigin) {
231
326
  return new Response(null, { status: 403 });
232
327
  }
@@ -235,10 +330,14 @@ async function answerHTTPRequest(
235
330
  return new Response(null, { status: 405, headers: { allow: 'POST' } });
236
331
  }
237
332
 
333
+ const body = await request.text();
334
+
335
+ onRPC(findRPCLabel(body));
336
+
238
337
  return answerMCPRequest(ctx, {
239
338
  authorization: request.headers.get('authorization'),
240
339
  protocolVersion: request.headers.get('mcp-protocol-version'),
241
- body: await request.text(),
340
+ body,
242
341
  });
243
342
  }
244
343
 
@@ -253,7 +352,8 @@ function toPublicRequest(ctx: HTTPServerContext, request: Request, url: URL): Re
253
352
  }
254
353
 
255
354
  // The metadata advertises only what atc's clients can use: public clients
256
- // with no client authentication, and no introspection endpoint.
355
+ // with no client authentication, no introspection endpoint, and Bearer
356
+ // tokens alone, so nothing about DPoP.
257
357
  async function answerMetadataRequest(
258
358
  ctx: HTTPServerContext,
259
359
  request: Request,
@@ -267,9 +367,15 @@ async function answerMetadataRequest(
267
367
  }
268
368
 
269
369
  const advertised = Object.fromEntries(
270
- Object.entries(metadata).filter(([key]) => !key.startsWith('introspection_')),
370
+ Object.entries(metadata).filter(
371
+ ([key]) => !key.startsWith('introspection_') && !key.startsWith('dpop_'),
372
+ ),
271
373
  );
272
374
 
375
+ if (RESOURCE_METADATA.has(`${request.method} ${url.pathname}`)) {
376
+ return Response.json(advertised, { status: response.status });
377
+ }
378
+
273
379
  return Response.json({
274
380
  ...advertised,
275
381
  token_endpoint_auth_methods_supported: ['none'],
package/src/mcp/types.ts CHANGED
@@ -1,12 +1,18 @@
1
+ import type { DaemonFeature } from '../protocol/daemon-features';
1
2
  import type { ApprovalState } from './approval-state';
2
3
  import type { openMCPAuth } from './open-mcp-auth';
3
4
 
4
- // The slice of the daemon client the tool handlers need.
5
+ // The slice of the daemon client the tool handlers need: requests, and the
6
+ // features the connected daemon announced at its handshake. A request that
7
+ // lists required features is checked against the connection it is about to
8
+ // ride, every time it is sent, and refused unsent when that daemon lacks one.
5
9
  export interface FleetCaller {
6
10
  readonly sendRequest: (
7
11
  m: string,
8
12
  p?: Readonly<Record<string, unknown>>,
13
+ required?: readonly DaemonFeature[],
9
14
  ) => Promise<Readonly<Record<string, unknown>>>;
15
+ readonly readFeatures: () => Promise<ReadonlySet<DaemonFeature>>;
10
16
  }
11
17
 
12
18
  export interface ToolContext {
@@ -43,9 +43,14 @@ export async function runMCPHTTPServer(build: string, flags: MCPHTTPFlags): Prom
43
43
  printApproval: (line) => {
44
44
  console.log(line.replaceAll(/[\p{Cc}\p{Cf}]/gu, ''));
45
45
  },
46
+
47
+ // Request lines go to stderr, so stdout keeps the approval lines alone.
48
+ printRequest: (line) => {
49
+ console.error(line);
50
+ },
46
51
  });
47
52
 
48
- console.log(`atc mcp --http: serving ${server.origin}/mcp, listening on ${server.url}`);
53
+ console.log(`atc mcp --http: serving ${server.origin}/mcp, listening on ${server.listening}`);
49
54
 
50
55
  const admin = await openMCPAuth({ dbPath: mcpAuthDBFile, origin: null });
51
56
  const clients = await collectClients(admin.db);
package/src/mcp-server.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { bootDaemonClient } from './client/boot-daemon';
2
2
  import { answerRPCRequest } from './mcp/answer-rpc-request';
3
+ import { requireDaemonFeatures } from './mcp/require-daemon-features';
3
4
  import type { FleetCaller, ToolContext } from './mcp/types';
4
5
 
5
6
  /**
@@ -12,6 +13,17 @@ export async function runMCPServer(build: string): Promise<void> {
12
13
 
13
14
  const client = boot.client;
14
15
 
16
+ // The connection lives as long as the server, so the features its
17
+ // handshake announced hold for every call.
18
+ const caller: FleetCaller = {
19
+ sendRequest: (m, p, required = []) => {
20
+ requireDaemonFeatures(boot.features, required);
21
+
22
+ return client.sendRequest(m, p);
23
+ },
24
+ readFeatures: () => Promise.resolve(boot.features),
25
+ };
26
+
15
27
  // The server inherits the calling session's id from its environment, so a
16
28
  // spawn from inside a session nests under it by default.
17
29
  const inherited = process.env['ATC_SESSION_ID'];
@@ -48,7 +60,7 @@ export async function runMCPServer(build: string): Promise<void> {
48
60
 
49
61
  void (async () => {
50
62
  try {
51
- await answerRPCLine(client, build, toolContext, line);
63
+ await answerRPCLine(caller, build, toolContext, line);
52
64
  } catch {
53
65
  // A failed line gets no response, the way a malformed one gets none.
54
66
  } finally {
@@ -0,0 +1,23 @@
1
+ /**
2
+ * The request features a daemon announces in its `daemon.hello` answer. A
3
+ * daemon from before the list existed announces none, so a client that
4
+ * outlives an upgrade sees exactly what the running daemon serves.
5
+ */
6
+ export const DAEMON_FEATURES = [
7
+ // `agents.list` exists.
8
+ 'agents.list',
9
+
10
+ // `events.read` returns `more`.
11
+ 'events.more',
12
+
13
+ // `events.read` takes a `session` filter.
14
+ 'events.session',
15
+
16
+ // `message.get` returns `turn` and `answeredWith`.
17
+ 'message.turn',
18
+
19
+ // `message.get` takes `waitMs`.
20
+ 'message.wait',
21
+ ] as const;
22
+
23
+ export type DaemonFeature = (typeof DAEMON_FEATURES)[number];
@@ -0,0 +1,19 @@
1
+ import type { DaemonFeature } from './daemon-features';
2
+ import { DAEMON_FEATURES } from './daemon-features';
3
+
4
+ /**
5
+ * Reads the features a `daemon.hello` answer announces. A missing or
6
+ * malformed list, which an older daemon sends, is no features, and a name
7
+ * this build does not know is dropped.
8
+ */
9
+ export function parseDaemonFeatures(
10
+ hello: Readonly<Record<string, unknown>>,
11
+ ): ReadonlySet<DaemonFeature> {
12
+ const announced = hello['features'];
13
+
14
+ if (!Array.isArray(announced)) {
15
+ return new Set();
16
+ }
17
+
18
+ return new Set(DAEMON_FEATURES.filter((feature) => announced.includes(feature)));
19
+ }
@@ -24,6 +24,7 @@ export const REQUEST_PARAM_SCHEMAS = {
24
24
  'daemon.quit': z.object({}),
25
25
  'session.list': z.object({}),
26
26
  'dirs.list': z.object({}),
27
+ 'agents.list': z.object({}),
27
28
  'fleet.list': z.object({}),
28
29
  'fleet.restore': z.object({
29
30
  cols: buildDefaultedNumber(80),
@@ -96,7 +97,13 @@ export const REQUEST_PARAM_SCHEMAS = {
96
97
  'events.read': z.object({
97
98
  cursor: buildOptionalCursor(),
98
99
  limit: buildDefaultedNumber(50).transform((v) => Math.min(Math.max(Math.trunc(v), 1), 200)),
99
- waitMs: buildDefaultedNumber(0).transform((v) => Math.min(Math.max(Math.trunc(v), 0), 30_000)),
100
+ waitMs: buildDefaultedWait(),
101
+
102
+ // Limits the read to one session's events; absent or empty reads the whole fleet.
103
+ session: z.preprocess(
104
+ (v) => (typeof v === 'string' && v !== '' ? v : undefined),
105
+ z.string().transform(toSessionID).optional(),
106
+ ),
100
107
  }),
101
108
  'session.message': SESSION_DEFAULTED.extend({
102
109
  from: buildDefaultedNonEmptyString('unknown'),
@@ -104,7 +111,10 @@ export const REQUEST_PARAM_SCHEMAS = {
104
111
  }).refine((v) => v.text !== '', { message: 'session.message requires text' }),
105
112
  'session.tap': SESSION_DEFAULTED,
106
113
  'message.get': z
107
- .object({ message: buildDefaultedString('').transform(toMessageID) })
114
+ .object({
115
+ message: buildDefaultedString('').transform(toMessageID),
116
+ waitMs: buildDefaultedWait(),
117
+ })
108
118
  .refine((v) => v.message !== '', { message: 'message.get requires a message' }),
109
119
  'message.ack': SESSION_DEFAULTED.extend({
110
120
  message: buildDefaultedString('').transform(toMessageID),
@@ -119,6 +129,12 @@ function buildDefaultedNumber(fallback: number) {
119
129
  return z.preprocess((v) => (typeof v === 'number' ? v : undefined), z.number().default(fallback));
120
130
  }
121
131
 
132
+ // How long a read may hold its request open, in milliseconds: 0 when absent,
133
+ // clamped to 0–30000.
134
+ function buildDefaultedWait() {
135
+ return buildDefaultedNumber(0).transform((v) => Math.min(Math.max(Math.trunc(v), 0), 30_000));
136
+ }
137
+
122
138
  function buildDefaultedBooleanOrString(fallback: boolean | string) {
123
139
  return z.preprocess(
124
140
  (v) => (typeof v === 'boolean' || typeof v === 'string' ? v : undefined),
package/src/report.ts CHANGED
@@ -3,13 +3,21 @@ import { REPORT_KINDS } from './shared/report-kinds';
3
3
 
4
4
  interface ReportOptions {
5
5
  readonly message: string;
6
+
7
+ // Comma-separated ids of every message one turn answered, reported together.
8
+ readonly messages: string;
6
9
  readonly label: string;
10
+
11
+ // The turn whose final reply an `answered` report carries; empty when unknown.
12
+ readonly turn: string;
7
13
  }
8
14
 
9
15
  /**
10
16
  * Runs inside wrangled sessions: reads stdin verbatim and forwards it to the
11
17
  * atc socket as a Report envelope of the given kind. An `answered` report
12
- * carries stdin as the final text for the given message; a `note` carries
18
+ * carries stdin as the final reply of the turn that carried the given
19
+ * message, or every given message at once, plus that turn's id when one is
20
+ * given; a `note` carries
13
21
  * it as text for the user under the given label, `progress` when none is
14
22
  * given. Always exits 0 so it never blocks the session it reports on.
15
23
  */
@@ -44,9 +52,18 @@ function buildReportPayload(
44
52
  kind: string,
45
53
  options: ReportOptions,
46
54
  stdin: string,
47
- ): Record<string, string> | null {
55
+ ): Record<string, string | readonly string[]> | null {
48
56
  if (kind === 'answered') {
49
- return options.message === '' ? null : { kind, message: options.message, answer: stdin };
57
+ const messages = options.messages.split(',').filter((id) => id !== '');
58
+ const turn = options.turn === '' ? {} : { turn: options.turn };
59
+
60
+ if (messages.length > 0) {
61
+ return { kind, messages, answer: stdin, ...turn };
62
+ }
63
+
64
+ return options.message === ''
65
+ ? null
66
+ : { kind, message: options.message, answer: stdin, ...turn };
50
67
  }
51
68
 
52
69
  if (kind === 'note') {
@@ -15,4 +15,7 @@ export interface MessageRecord {
15
15
  readonly deliveredAt?: number;
16
16
  readonly answeredAt?: number;
17
17
  readonly answer?: string;
18
+
19
+ // The turn whose final reply the answer is, when the reporter gave one.
20
+ readonly turn?: string;
18
21
  }