@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.
@@ -4,8 +4,14 @@ import { toMessageID } from '../shared/to-message-id';
4
4
 
5
5
  interface AnsweredReport {
6
6
  readonly kind: 'answered';
7
- readonly message: MessageID;
7
+
8
+ // Every message the turn answered, recorded together.
9
+ readonly messages: readonly MessageID[];
8
10
  readonly answer: string;
11
+
12
+ // The turn whose final reply the answer is; null from a reporter that
13
+ // sends none.
14
+ readonly turn: string | null;
9
15
  }
10
16
 
11
17
  export interface NoteReport {
@@ -16,8 +22,27 @@ export interface NoteReport {
16
22
 
17
23
  export type Report = AnsweredReport | NoteReport;
18
24
 
25
+ // Optional, so a report from an older bridge still parses; a missing, empty,
26
+ // or wrong-typed turn reads as unknown.
27
+ const TURN_SCHEMA = z.preprocess(
28
+ (v) => (typeof v === 'string' && v !== '' ? v : undefined),
29
+ z.string().optional(),
30
+ );
31
+
32
+ const MESSAGE_IDS_SCHEMA = z.array(z.string().min(1)).min(1).optional();
33
+
19
34
  const REPORT_SCHEMA = z.discriminatedUnion('kind', [
20
- z.object({ kind: z.literal('answered'), message: z.string().min(1), answer: z.string() }),
35
+ z
36
+ .object({
37
+ kind: z.literal('answered'),
38
+
39
+ // One message, or every message one turn answered.
40
+ message: z.string().min(1).optional(),
41
+ messages: MESSAGE_IDS_SCHEMA,
42
+ answer: z.string(),
43
+ turn: TURN_SCHEMA,
44
+ })
45
+ .refine((v) => (v.message === undefined) !== (v.messages === undefined)),
21
46
  z.object({ kind: z.literal('note'), label: z.string().min(1).max(64), text: z.string().min(1) }),
22
47
  ]);
23
48
 
@@ -38,7 +63,8 @@ export function parseReport(payload: Readonly<Record<string, unknown>>): Report
38
63
 
39
64
  return {
40
65
  kind: 'answered',
41
- message: toMessageID(parsed.data.message),
66
+ messages: (parsed.data.messages ?? [parsed.data.message ?? '']).map((id) => toMessageID(id)),
42
67
  answer: parsed.data.answer,
68
+ turn: parsed.data.turn ?? null,
43
69
  };
44
70
  }
@@ -134,6 +134,11 @@ export class SessionManager {
134
134
  return this.adapters[id] ?? null;
135
135
  }
136
136
 
137
+ // Every registered adapter, one per id, in registration order.
138
+ collectAdapters(): AgentAdapter[] {
139
+ return Object.values(this.adapters);
140
+ }
141
+
137
142
  // Hands a live terminal session off to a headless run: the terminal dies,
138
143
  // the record lives on as a headless session and keeps its screen history.
139
144
  yankHeadless(id: SessionID): Session | null {
@@ -57,10 +57,14 @@ export async function answerRPCRequest(message: unknown, deps: RPCDeps): Promise
57
57
  }),
58
58
  }))
59
59
  .with('ping', () => ({ kind: 'reply' as const, body: buildRPCResult(id, {}) }))
60
- .with('tools/list', () => ({
61
- kind: 'reply' as const,
62
- body: buildRPCResult(id, { tools: buildToolList() }),
63
- }))
60
+ .with('tools/list', async () => {
61
+ const features = await deps.caller.readFeatures();
62
+
63
+ return {
64
+ kind: 'reply' as const,
65
+ body: buildRPCResult(id, { tools: buildToolList(features) }),
66
+ };
67
+ })
64
68
  .with('tools/call', async () => {
65
69
  const result = await answerToolCall(deps, params);
66
70
 
@@ -101,9 +105,12 @@ async function answerToolCall(
101
105
  const args = isRecord(params['arguments']) ? params['arguments'] : {};
102
106
 
103
107
  try {
104
- const text = await runTool(deps.caller, name, args, deps.toolContext);
108
+ const result = await runTool(deps.caller, name, args, deps.toolContext);
105
109
 
106
- return { content: [{ type: 'text', text }] };
110
+ return {
111
+ content: [{ type: 'text', text: result.text }],
112
+ ...(result.structured === null ? {} : { structuredContent: result.structured }),
113
+ };
107
114
  } catch (error) {
108
115
  return { content: [{ type: 'text', text: formatToolError(error) }], isError: true };
109
116
  }
@@ -1,9 +1,12 @@
1
+ import type { DaemonFeature } from '../protocol/daemon-features';
2
+ import { isRecord } from '../shared/report';
1
3
  import { MCP_TOOLS } from './mcp-tools';
2
4
 
3
5
  interface MCPTool {
4
6
  readonly name: string;
5
7
  readonly description: string;
6
8
  readonly inputSchema: Readonly<Record<string, unknown>>;
9
+ readonly outputSchema?: Readonly<Record<string, unknown>>;
7
10
  readonly annotations: {
8
11
  readonly readOnlyHint: boolean;
9
12
  readonly destructiveHint: boolean;
@@ -11,11 +14,61 @@ interface MCPTool {
11
14
  };
12
15
  }
13
16
 
14
- export function buildToolList(): readonly MCPTool[] {
15
- return MCP_TOOLS.map((tool) => ({
16
- name: tool.name,
17
- description: tool.description,
18
- inputSchema: tool.inputSchema,
19
- annotations: tool.annotations,
20
- }));
17
+ /**
18
+ * The tools `tools/list` returns for a daemon announcing the given
19
+ * features. A tool the daemon cannot serve is left out, and a tool it serves
20
+ * in an older form is listed without the output schema and input properties
21
+ * that form lacks, so a client never sees an option the daemon would ignore.
22
+ */
23
+ export function buildToolList(features: ReadonlySet<DaemonFeature>): readonly MCPTool[] {
24
+ return MCP_TOOLS.flatMap((tool) => {
25
+ const requires = tool.requires ?? {};
26
+
27
+ if (requires.tool !== undefined && !features.has(requires.tool)) {
28
+ return [];
29
+ }
30
+
31
+ const inputSchema = buildInputSchema(tool.inputSchema, requires.inputs ?? {}, features);
32
+
33
+ return [
34
+ tool.outputSchema === undefined ||
35
+ (requires.output !== undefined && !features.has(requires.output))
36
+ ? {
37
+ name: tool.name,
38
+ description: tool.description,
39
+ inputSchema,
40
+ annotations: tool.annotations,
41
+ }
42
+ : {
43
+ name: tool.name,
44
+ description: tool.description,
45
+ inputSchema,
46
+ outputSchema: tool.outputSchema,
47
+ annotations: tool.annotations,
48
+ },
49
+ ];
50
+ });
51
+ }
52
+
53
+ function buildInputSchema(
54
+ schema: Readonly<Record<string, unknown>>,
55
+ inputs: Readonly<Record<string, DaemonFeature>>,
56
+ features: ReadonlySet<DaemonFeature>,
57
+ ): Readonly<Record<string, unknown>> {
58
+ const withheld = Object.entries(inputs).flatMap(([name, feature]) =>
59
+ features.has(feature) ? [] : [name],
60
+ );
61
+
62
+ const properties = schema['properties'];
63
+
64
+ if (withheld.length === 0 || !isRecord(properties)) {
65
+ return schema;
66
+ }
67
+
68
+ return {
69
+ ...schema,
70
+ properties: Object.fromEntries(
71
+ Object.entries(properties).filter(([name]) => !withheld.includes(name)),
72
+ ),
73
+ };
21
74
  }
@@ -1,4 +1,5 @@
1
1
  import { z } from 'zod';
2
+ import type { DaemonFeature } from '../protocol/daemon-features';
2
3
  import { REQUEST_PARAM_SCHEMAS } from '../protocol/request-param-schemas';
3
4
  import type { GrantScope } from '../shared/grant-scope';
4
5
 
@@ -54,8 +55,16 @@ const SESSION_READ_INPUT: Readonly<Record<string, unknown>> = z.toJSONSchema(
54
55
  { io: 'input' },
55
56
  );
56
57
 
58
+ const WAIT_MS = z.number().int().min(0).max(30_000).optional();
59
+
57
60
  const EVENTS_READ_INPUT: Readonly<Record<string, unknown>> = z.toJSONSchema(
58
61
  z.strictObject({
62
+ session: z
63
+ .string()
64
+ .optional()
65
+ .describe(
66
+ "An atc session id; limits the read to that session's events. Cursors stay valid across filtered and unfiltered reads",
67
+ ),
59
68
  cursor: z
60
69
  .string()
61
70
  .optional()
@@ -69,19 +78,121 @@ const EVENTS_READ_INPUT: Readonly<Record<string, unknown>> = z.toJSONSchema(
69
78
  .max(200)
70
79
  .optional()
71
80
  .describe('Most events to return; defaults to 50'),
72
- waitMs: z
73
- .number()
74
- .int()
75
- .min(0)
76
- .max(30_000)
77
- .optional()
78
- .describe(
79
- 'How long to wait for a new event when none is pending, in milliseconds; defaults to 0, capped at 30000. Keep it short.',
80
- ),
81
+ waitMs: WAIT_MS.describe(
82
+ 'How long to wait for a new event when none is pending, in milliseconds; defaults to 0, capped at 30000. Keep it short.',
83
+ ),
81
84
  }),
82
85
  { io: 'input' },
83
86
  );
84
87
 
88
+ const MESSAGE_GET_INPUT: Readonly<Record<string, unknown>> = z.toJSONSchema(
89
+ z.strictObject({
90
+ message: z.string().describe('The message id atc_session_message returned'),
91
+ waitMs: WAIT_MS.describe(
92
+ 'How long to hold the call until the message status changes from what it was when you called, in milliseconds; defaults to 0, capped at 30000',
93
+ ),
94
+ }),
95
+ { io: 'input' },
96
+ );
97
+
98
+ // Output schemas leave further properties open, so a field the daemon adds
99
+ // later never fails a client that validates results against them.
100
+ const MESSAGE_OUTPUT: Readonly<Record<string, unknown>> = {
101
+ type: 'object',
102
+ properties: {
103
+ message: { type: 'string' },
104
+ session: { type: 'string' },
105
+ from: { type: 'string' },
106
+ text: { type: 'string' },
107
+ status: { type: 'string', enum: ['accepted', 'delivered', 'answered'] },
108
+ answer: { type: 'string' },
109
+ turn: { type: ['string', 'null'] },
110
+ answeredWith: { type: 'array', items: { type: 'string' } },
111
+ sentAt: { type: 'number' },
112
+ deliveredAt: { type: 'number' },
113
+ answeredAt: { type: 'number' },
114
+ },
115
+ required: ['message', 'session', 'from', 'text', 'status', 'turn', 'answeredWith', 'sentAt'],
116
+ };
117
+
118
+ const MESSAGE_SENT_OUTPUT: Readonly<Record<string, unknown>> = {
119
+ type: 'object',
120
+ properties: {
121
+ message: { type: 'string' },
122
+ status: { type: 'string', enum: ['accepted', 'delivered', 'answered'] },
123
+ },
124
+ required: ['message', 'status'],
125
+ };
126
+
127
+ const AGENTS_OUTPUT: Readonly<Record<string, unknown>> = {
128
+ type: 'object',
129
+ properties: {
130
+ daemon: {
131
+ type: 'object',
132
+ properties: {
133
+ hostname: { type: 'string' },
134
+ platform: { type: 'string' },
135
+ arch: { type: 'string' },
136
+ build: { type: 'string' },
137
+ },
138
+ required: ['hostname', 'platform', 'arch', 'build'],
139
+ },
140
+ agents: {
141
+ type: 'array',
142
+ items: {
143
+ type: 'object',
144
+ properties: {
145
+ id: { type: 'string' },
146
+ label: { type: 'string' },
147
+ kind: { type: 'string' },
148
+ installed: { type: 'boolean' },
149
+ capabilities: {
150
+ type: 'object',
151
+ properties: {
152
+ spawn: { type: 'boolean' },
153
+ readTranscript: { type: 'boolean' },
154
+ message: { type: 'boolean' },
155
+ attach: { type: 'boolean' },
156
+ screen: { type: 'boolean' },
157
+ input: { type: 'boolean' },
158
+ },
159
+ required: ['spawn', 'readTranscript', 'message', 'attach', 'screen', 'input'],
160
+ },
161
+ models: { type: ['object', 'null'], additionalProperties: { type: 'string' } },
162
+ },
163
+ required: ['id', 'label', 'kind', 'installed', 'capabilities', 'models'],
164
+ },
165
+ },
166
+ },
167
+ required: ['daemon', 'agents'],
168
+ };
169
+
170
+ const EVENTS_OUTPUT: Readonly<Record<string, unknown>> = {
171
+ type: 'object',
172
+ properties: {
173
+ events: {
174
+ type: 'array',
175
+ items: {
176
+ type: 'object',
177
+ properties: {
178
+ cursor: { type: 'string' },
179
+ at: { type: 'number' },
180
+ session: { type: 'string' },
181
+ name: { type: ['string', 'null'] },
182
+ kind: { type: 'string' },
183
+ detail: { type: ['string', 'null'] },
184
+ message: { type: 'string' },
185
+ label: { type: 'string' },
186
+ },
187
+ required: ['cursor', 'at', 'session', 'name', 'kind', 'detail'],
188
+ },
189
+ },
190
+ cursor: { type: 'string' },
191
+ more: { type: 'boolean' },
192
+ },
193
+ required: ['events', 'cursor', 'more'],
194
+ };
195
+
85
196
  interface MCPToolAnnotations {
86
197
  readonly readOnlyHint: boolean;
87
198
  readonly destructiveHint: boolean;
@@ -92,7 +203,19 @@ interface MCPToolDefinition {
92
203
  readonly name: string;
93
204
  readonly description: string;
94
205
  readonly inputSchema: Readonly<Record<string, unknown>>;
206
+
207
+ // The shape of the tool's structured result, for the tools that declare one.
208
+ readonly outputSchema?: Readonly<Record<string, unknown>>;
95
209
  readonly annotations: MCPToolAnnotations;
210
+
211
+ // What the connected daemon has to announce for the tool to be listed at
212
+ // all, for its output schema to be declared, and for each listed input
213
+ // property to be offered. An older daemon gets the tool without them.
214
+ readonly requires?: {
215
+ readonly tool?: DaemonFeature;
216
+ readonly output?: DaemonFeature;
217
+ readonly inputs?: Readonly<Record<string, DaemonFeature>>;
218
+ };
96
219
  readonly scope: GrantScope;
97
220
  }
98
221
 
@@ -215,6 +338,16 @@ export const MCP_TOOLS: readonly MCPToolDefinition[] = [
215
338
  description: 'List directories sessions were previously spawned from, most recent first.',
216
339
  inputSchema: NO_INPUT,
217
340
  },
341
+ {
342
+ name: 'atc_agents_list',
343
+ annotations: READ_ONLY,
344
+ scope: 'read',
345
+ description:
346
+ "List the agents this atc host can run sessions under, plus the host itself (daemon: hostname, platform, arch, build). Each agent has its id (pass it as atc_session_spawn's agent), label, kind (the agent CLI family it runs), installed (whether its binary resolves on this host; a registered agent that is not installed cannot spawn), capabilities (spawn, readTranscript, message, attach, screen, input), and models: the model names the config sets for it, or null. It never includes credentials, environment values, or endpoints, and holds nothing about which plans or subscriptions an agent's account has.",
347
+ inputSchema: NO_INPUT,
348
+ outputSchema: AGENTS_OUTPUT,
349
+ requires: { tool: 'agents.list' },
350
+ },
218
351
  {
219
352
  name: 'atc_session_get',
220
353
  annotations: READ_ONLY,
@@ -236,15 +369,17 @@ export const MCP_TOOLS: readonly MCPToolDefinition[] = [
236
369
  annotations: READ_ONLY,
237
370
  scope: 'read',
238
371
  description:
239
- '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. waitMs holds the call open until an event arrives.',
372
+ '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.',
240
373
  inputSchema: EVENTS_READ_INPUT,
374
+ outputSchema: EVENTS_OUTPUT,
375
+ requires: { output: 'events.more', inputs: { session: 'events.session' } },
241
376
  },
242
377
  {
243
378
  name: 'atc_session_message',
244
379
  annotations: AGENT_FACING,
245
380
  scope: 'message',
246
381
  description:
247
- "Send a session a message and get its id back. Follow up by polling atc_message_get with the id until its status is answered, which returns the session's final reply; don't read the session's screen or transcript to check on it. The message waits in the session inbox until the session takes it, and its status moves accepted, delivered, answered. A message is refused as unsupported when the session's agent has no message tap (Grok, Codex), or when a Claude session reported SessionStart more than 15 seconds ago and no tap has attached since. It is refused as session_dead when the session has no live process and as no_such_session for an unknown id. Otherwise it queues, including while a session restores or after its tap dropped. The message is never typed into the terminal.",
382
+ "Send a session a message and get its id back. Follow up with atc_message_get, passing waitMs so each call waits for the next status change instead of polling in a tight loop, until its status is answered; don't read the session's screen or transcript to check on it. The answer is the final output of the session turn that carried the message, and one turn can carry several messages. The message waits in the session inbox until the session takes it, and its status moves accepted, delivered, answered. A message is refused as unsupported when the session's agent has no message tap (Grok, Codex), or when a Claude session reported SessionStart more than 15 seconds ago and no tap has attached since. It is refused as session_dead when the session has no live process and as no_such_session for an unknown id. Otherwise it queues, including while a session restores or after its tap dropped. The message is never typed into the terminal.",
248
383
  inputSchema: {
249
384
  type: 'object',
250
385
  properties: {
@@ -259,20 +394,16 @@ export const MCP_TOOLS: readonly MCPToolDefinition[] = [
259
394
  required: ['session', 'text'],
260
395
  additionalProperties: false,
261
396
  },
397
+ outputSchema: MESSAGE_SENT_OUTPUT,
262
398
  },
263
399
  {
264
400
  name: 'atc_message_get',
265
401
  annotations: READ_ONLY,
266
402
  scope: 'read',
267
403
  description:
268
- 'Read one message sent with atc_session_message: its id, session, from, text, status (accepted, delivered, or answered), the answer once answered, and the sentAt, deliveredAt, and answeredAt timestamps. Poll it until the status is answered.',
269
- inputSchema: {
270
- type: 'object',
271
- properties: {
272
- message: { type: 'string', description: 'The message id atc_session_message returned' },
273
- },
274
- required: ['message'],
275
- additionalProperties: false,
276
- },
404
+ 'Read one message sent with atc_session_message: its id, session, from, text, status (accepted, delivered, or answered), the answer once answered, turn, answeredWith, and the sentAt, deliveredAt, and answeredAt timestamps. The answer is the final output of the session turn that carried the message, not a reply to that message alone: when one turn carries several messages, each gets the same answer. turn is that turn id, or null when the session reported none, and answeredWith lists the other messages the same turn answered. Pass waitMs to hold the call until the status changes from what it was when you called, up to 30000 ms, instead of polling in a tight loop; an answered message returns at once. Message ids and statuses persist, so after a call ends or times out, call again with the same id.',
405
+ inputSchema: MESSAGE_GET_INPUT,
406
+ outputSchema: MESSAGE_OUTPUT,
407
+ requires: { output: 'message.turn', inputs: { waitMs: 'message.wait' } },
277
408
  },
278
409
  ];
@@ -1,9 +1,13 @@
1
1
  import { DaemonClient } from '../client/daemon-client';
2
+ import type { DaemonFeature } from '../protocol/daemon-features';
3
+ import { parseDaemonFeatures } from '../protocol/parse-daemon-features';
4
+ import { requireDaemonFeatures } from './require-daemon-features';
2
5
  import type { FleetCaller } from './types';
3
6
 
4
7
  // The daemon requests the read-only tools send. Each only reads, so running
5
8
  // one twice is harmless.
6
9
  const RETRYABLE_METHODS: ReadonlySet<string> = new Set([
10
+ 'agents.list',
7
11
  'dirs.list',
8
12
  'events.read',
9
13
  'message.get',
@@ -14,6 +18,12 @@ const RETRYABLE_METHODS: ReadonlySet<string> = new Set([
14
18
  'session.screen',
15
19
  ]);
16
20
 
21
+ // One handshaken connection and the features its daemon announced.
22
+ interface DaemonConnection {
23
+ readonly client: DaemonClient;
24
+ readonly features: ReadonlySet<DaemonFeature>;
25
+ }
26
+
17
27
  /**
18
28
  * A daemon caller that survives a daemon restart: once the connection ends,
19
29
  * the next request opens and handshakes a fresh one. A request that was in
@@ -26,7 +36,7 @@ export class ReconnectingCaller implements FleetCaller {
26
36
 
27
37
  private readonly build: string;
28
38
 
29
- private client: Promise<DaemonClient> | null = null;
39
+ private client: Promise<DaemonConnection> | null = null;
30
40
 
31
41
  private readonly closed = new WeakSet<DaemonClient>();
32
42
 
@@ -35,25 +45,41 @@ export class ReconnectingCaller implements FleetCaller {
35
45
  this.build = build;
36
46
  }
37
47
 
48
+ // The required features are checked against each connection right before
49
+ // the request goes out on it, the retry's fresh connection included, since
50
+ // a restart can put an older daemon behind the same socket.
38
51
  async sendRequest(
39
52
  m: string,
40
53
  p?: Readonly<Record<string, unknown>>,
54
+ required: readonly DaemonFeature[] = [],
41
55
  ): Promise<Readonly<Record<string, unknown>>> {
42
- const client = await this.openClient();
56
+ const opened = await this.openClient();
57
+
58
+ requireDaemonFeatures(opened.features, required);
43
59
 
44
60
  try {
45
- return await client.sendRequest(m, p);
61
+ return await opened.client.sendRequest(m, p);
46
62
  } catch (error) {
47
- if (!this.closed.has(client) || !RETRYABLE_METHODS.has(m)) {
63
+ if (!this.closed.has(opened.client) || !RETRYABLE_METHODS.has(m)) {
48
64
  throw error;
49
65
  }
50
66
 
51
67
  const fresh = await this.openClient();
52
68
 
53
- return fresh.sendRequest(m, p);
69
+ requireDaemonFeatures(fresh.features, required);
70
+
71
+ return fresh.client.sendRequest(m, p);
54
72
  }
55
73
  }
56
74
 
75
+ // The features of the daemon the next request reaches, read from the
76
+ // handshake of the connection it rides.
77
+ async readFeatures(): Promise<ReadonlySet<DaemonFeature>> {
78
+ const opened = await this.openClient();
79
+
80
+ return opened.features;
81
+ }
82
+
57
83
  async stop(): Promise<void> {
58
84
  const current = this.client;
59
85
 
@@ -64,17 +90,19 @@ export class ReconnectingCaller implements FleetCaller {
64
90
  }
65
91
 
66
92
  try {
67
- const client = await current;
93
+ const opened = await current;
68
94
 
69
- client.stop();
95
+ opened.client.stop();
70
96
  } catch {
71
97
  // A connection that never opened has nothing to close.
72
98
  }
73
99
  }
74
100
 
75
- private openClient(): Promise<DaemonClient> {
101
+ private openClient(): Promise<DaemonConnection> {
76
102
  if (this.client === null) {
77
- const opening: Promise<DaemonClient> = this.openFreshClient(() => this.client === opening);
103
+ const opening: Promise<DaemonConnection> = this.openFreshClient(
104
+ () => this.client === opening,
105
+ );
78
106
 
79
107
  this.client = opening;
80
108
  }
@@ -85,7 +113,7 @@ export class ReconnectingCaller implements FleetCaller {
85
113
  // isCurrent returns true while this connection is still the one later requests
86
114
  // reuse: a connection that ends after a newer one replaced it must leave
87
115
  // the newer one in place.
88
- private async openFreshClient(isCurrent: () => boolean): Promise<DaemonClient> {
116
+ private async openFreshClient(isCurrent: () => boolean): Promise<DaemonConnection> {
89
117
  const resetClient = () => {
90
118
  if (isCurrent()) {
91
119
  this.client = null;
@@ -107,8 +135,10 @@ export class ReconnectingCaller implements FleetCaller {
107
135
  resetClient();
108
136
  };
109
137
 
138
+ let hello: Readonly<Record<string, unknown>>;
139
+
110
140
  try {
111
- await client.sendHello(this.build);
141
+ hello = await client.sendHello(this.build);
112
142
  } catch (error) {
113
143
  client.stop();
114
144
 
@@ -116,6 +146,6 @@ export class ReconnectingCaller implements FleetCaller {
116
146
  throw error;
117
147
  }
118
148
 
119
- return client;
149
+ return { client, features: parseDaemonFeatures(hello) };
120
150
  }
121
151
  }
@@ -0,0 +1,33 @@
1
+ import type { DaemonFeature } from '../protocol/daemon-features';
2
+
3
+ // What each feature lets a tool call ask for, as an outdated-daemon refusal
4
+ // states it.
5
+ const FEATURE_USES: Readonly<Record<DaemonFeature, string>> = {
6
+ 'agents.list': 'atc_agents_list',
7
+ 'events.more': "atc_events_read's more flag",
8
+ 'events.session': "atc_events_read's session filter",
9
+ 'message.turn': "atc_message_get's turn and answeredWith",
10
+ 'message.wait': "atc_message_get's waitMs",
11
+ };
12
+
13
+ /**
14
+ * Throws a `daemon_outdated` error when the daemon a request is about to
15
+ * reach lacks a feature the request depends on, rather than letting a daemon
16
+ * that would ignore the option answer as if it had honoured it. atc never
17
+ * restarts the daemon itself: a restart is the operator's call, because it
18
+ * respawns every session.
19
+ */
20
+ export function requireDaemonFeatures(
21
+ features: ReadonlySet<DaemonFeature>,
22
+ required: readonly DaemonFeature[],
23
+ ): void {
24
+ const missing = required.find((feature) => !features.has(feature));
25
+
26
+ if (missing === undefined) {
27
+ return;
28
+ }
29
+
30
+ throw new Error(
31
+ `daemon_outdated: the running atc daemon is older than this atc and does not support ${FEATURE_USES[missing]}. Restart the daemon to use it: press u in the atc TUI, which restores every session. Until then, call without it.`,
32
+ );
33
+ }