@zgeoff/atc 2.10.4 → 2.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/README.md +3 -3
  2. package/package.json +1 -1
  3. package/src/agents/agent-adapter.ts +72 -0
  4. package/src/agents/atc-bridge-files.ts +1 -1
  5. package/src/agents/build-args-without-flags.ts +25 -0
  6. package/src/agents/build-claude-override-args.ts +26 -0
  7. package/src/agents/claude-adapter.ts +52 -1
  8. package/src/agents/claude-effort-levels.ts +5 -0
  9. package/src/agents/codex-adapter.ts +46 -1
  10. package/src/agents/find-flag-value.ts +27 -0
  11. package/src/agents/gateway-adapter.ts +75 -1
  12. package/src/agents/grok-adapter.ts +22 -0
  13. package/src/cli.ts +22 -0
  14. package/src/client/boot-daemon.ts +6 -0
  15. package/src/client/daemon-client.ts +14 -2
  16. package/src/client/spawn-picker.ts +27 -4
  17. package/src/daemon/build-agent-list.ts +94 -0
  18. package/src/daemon/build-config-revision.ts +27 -0
  19. package/src/daemon/build-execution-targets.ts +34 -0
  20. package/src/daemon/build-fleet-events.ts +2 -1
  21. package/src/daemon/build-payload-hash.ts +31 -0
  22. package/src/daemon/build-scoped-context.ts +232 -0
  23. package/src/daemon/build-target-access.ts +33 -0
  24. package/src/daemon/build-target-forbidden-error.ts +13 -0
  25. package/src/daemon/build-target-identity.ts +22 -0
  26. package/src/daemon/build-target-list.ts +50 -0
  27. package/src/daemon/daemon-connection.ts +371 -99
  28. package/src/daemon/daemon.ts +537 -90
  29. package/src/daemon/effect-remains-error.ts +13 -0
  30. package/src/daemon/execution-provider.ts +103 -0
  31. package/src/daemon/find-execution-refusal.ts +104 -0
  32. package/src/daemon/idempotency-ledger.ts +164 -0
  33. package/src/daemon/local-pty-provider.ts +83 -0
  34. package/src/daemon/mint-session-id.ts +5 -5
  35. package/src/daemon/parse-report.ts +29 -3
  36. package/src/daemon/parse-spawn-overrides.ts +94 -0
  37. package/src/daemon/permission-registry.ts +14 -4
  38. package/src/daemon/restore-fleet.ts +66 -19
  39. package/src/daemon/session-runtime.ts +10 -0
  40. package/src/daemon/sessions.ts +287 -65
  41. package/src/daemon/start-headless-run.ts +11 -0
  42. package/src/daemon/start-headless-turn.ts +11 -2
  43. package/src/daemon/target-access.ts +36 -0
  44. package/src/mcp/answer-mcp-request.ts +12 -4
  45. package/src/mcp/answer-rpc-request.ts +40 -6
  46. package/src/mcp/build-principal-caller.ts +15 -0
  47. package/src/mcp/build-spawn-descriptions.ts +45 -0
  48. package/src/mcp/build-tool-list.ts +88 -7
  49. package/src/mcp/mcp-tools.ts +245 -24
  50. package/src/mcp/parse-idempotency-key.ts +29 -0
  51. package/src/mcp/reconnecting-caller.ts +77 -15
  52. package/src/mcp/require-daemon-features.ts +40 -0
  53. package/src/mcp/run-tool.ts +139 -35
  54. package/src/mcp/start-mcp-http-server.ts +130 -24
  55. package/src/mcp/types.ts +9 -1
  56. package/src/mcp-http-server.ts +6 -1
  57. package/src/mcp-server.ts +13 -1
  58. package/src/protocol/daemon-error.ts +5 -1
  59. package/src/protocol/daemon-features.ts +47 -0
  60. package/src/protocol/parse-daemon-features.ts +19 -0
  61. package/src/protocol/protocol.ts +44 -11
  62. package/src/protocol/request-param-schemas.ts +78 -12
  63. package/src/report.ts +20 -3
  64. package/src/shared/agent-session-id.ts +1 -1
  65. package/src/shared/collect-principals.ts +51 -0
  66. package/src/shared/collect-targets.ts +144 -0
  67. package/src/shared/config.ts +139 -14
  68. package/src/shared/daemon-id.ts +8 -0
  69. package/src/shared/format-json-kind.ts +21 -0
  70. package/src/shared/sort-json-keys.ts +19 -0
  71. package/src/shared/to-daemon-id.ts +11 -0
  72. package/src/store/fleet-entry.ts +42 -9
  73. package/src/store/idempotency-record.ts +48 -0
  74. package/src/store/message-owner.ts +1 -1
  75. package/src/store/message-record.ts +3 -0
  76. package/src/store/run-migrations.ts +272 -7
  77. package/src/store/state-store.ts +503 -34
  78. package/src/workspace/check-workspace-completeness.ts +90 -0
  79. package/src/workspace/create-workspace-clone.ts +239 -0
  80. package/src/workspace/normalize-git-url.ts +59 -0
  81. package/src/workspace/read-workspace-tar.ts +38 -0
  82. package/src/workspace/resolve-path-source.ts +170 -0
  83. package/src/workspace/run-git.ts +103 -0
  84. package/src/workspace/sanitize-workspace-clone.ts +146 -0
  85. package/src/workspace/workspace-provenance.ts +11 -0
  86. package/src/workspace/workspace-source.ts +8 -0
@@ -1,6 +1,9 @@
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';
5
+ import { buildSpawnDescriptions } from './build-spawn-descriptions';
6
+ import { IDEMPOTENCY_KEY_FIELD } from './parse-idempotency-key';
4
7
 
5
8
  const NO_INPUT: Readonly<Record<string, unknown>> = z.toJSONSchema(z.strictObject({}));
6
9
 
@@ -16,14 +19,28 @@ const SESSION_ID_BASE = z.object({
16
19
 
17
20
  const SESSION_INPUT: Readonly<Record<string, unknown>> = z.toJSONSchema(SESSION_ID_BASE.strict());
18
21
  const SPAWN_SCHEMA = REQUEST_PARAM_SCHEMAS['session.spawn'];
22
+ const SPAWN_AGENT_DESCRIPTION = buildSpawnDescriptions(null).agent;
23
+
24
+ // The key as a plain JSON Schema property, for an input schema written out
25
+ // by hand.
26
+ const { $schema: _, ...IDEMPOTENCY_KEY_INPUT } = z.toJSONSchema(IDEMPOTENCY_KEY_FIELD, {
27
+ io: 'input',
28
+ });
19
29
 
20
30
  const SPAWN_INPUT: Readonly<Record<string, unknown>> = z.toJSONSchema(
21
31
  z.strictObject({
22
32
  cwd: SPAWN_SCHEMA.shape.cwd.describe('Absolute path of the working directory'),
23
33
  name: SPAWN_SCHEMA.shape.name.describe('Session name; defaults to the directory basename'),
24
34
  prompt: SPAWN_SCHEMA.shape.prompt.describe('First message for the session'),
25
- agent: SPAWN_SCHEMA.shape.agent.describe(
26
- 'Which registered agent id to spawn; defaults to claude',
35
+ agent: SPAWN_SCHEMA.shape.agent.describe(SPAWN_AGENT_DESCRIPTION),
36
+ model: SPAWN_SCHEMA.shape.model.describe(
37
+ "Model for the new session: an alias or a full model name, at most 200 characters, never starting with '-'. It reaches the agent CLI as its own argument. Refused when the agent takes no model; spawnOptions.model in atc_agents_list holds each agent's support, default, and examples. Omit it to keep the agent's configured default.",
38
+ ),
39
+ effort: SPAWN_SCHEMA.shape.effort.describe(
40
+ "Effort level for the new session, one of the agent's spawnOptions.effort.values in atc_agents_list. Refused when the agent takes no effort. Omit it to keep the agent's configured default.",
41
+ ),
42
+ target: SPAWN_SCHEMA.shape.target.describe(
43
+ 'Execution target for the new session, one of the target ids in atc_agents_list. Omit it to run on the default target (spawnDefaults.target). An unknown or unavailable target is refused; atc never runs the session on another target instead.',
27
44
  ),
28
45
  detached: z
29
46
  .boolean()
@@ -31,6 +48,7 @@ const SPAWN_INPUT: Readonly<Record<string, unknown>> = z.toJSONSchema(
31
48
  .describe(
32
49
  'Spawn a top-level session. By default a spawn from inside an atc session becomes a sub-session of it: listed under it, pinned with it, killed with it.',
33
50
  ),
51
+ idempotencyKey: IDEMPOTENCY_KEY_FIELD,
34
52
  }),
35
53
  { io: 'input' },
36
54
  );
@@ -54,8 +72,16 @@ const SESSION_READ_INPUT: Readonly<Record<string, unknown>> = z.toJSONSchema(
54
72
  { io: 'input' },
55
73
  );
56
74
 
75
+ const WAIT_MS = z.number().int().min(0).max(30_000).optional();
76
+
57
77
  const EVENTS_READ_INPUT: Readonly<Record<string, unknown>> = z.toJSONSchema(
58
78
  z.strictObject({
79
+ session: z
80
+ .string()
81
+ .optional()
82
+ .describe(
83
+ "An atc session id; limits the read to that session's events. Cursors stay valid across filtered and unfiltered reads",
84
+ ),
59
85
  cursor: z
60
86
  .string()
61
87
  .optional()
@@ -69,19 +95,185 @@ const EVENTS_READ_INPUT: Readonly<Record<string, unknown>> = z.toJSONSchema(
69
95
  .max(200)
70
96
  .optional()
71
97
  .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
- ),
98
+ waitMs: WAIT_MS.describe(
99
+ 'How long to wait for a new event when none is pending, in milliseconds; defaults to 0, capped at 30000. Keep it short.',
100
+ ),
81
101
  }),
82
102
  { io: 'input' },
83
103
  );
84
104
 
105
+ const MESSAGE_GET_INPUT: Readonly<Record<string, unknown>> = z.toJSONSchema(
106
+ z.strictObject({
107
+ message: z.string().describe('The message id atc_session_message returned'),
108
+ waitMs: WAIT_MS.describe(
109
+ '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',
110
+ ),
111
+ }),
112
+ { io: 'input' },
113
+ );
114
+
115
+ // Output schemas leave further properties open, so a field the daemon adds
116
+ // later never fails a client that validates results against them.
117
+ const MESSAGE_OUTPUT: Readonly<Record<string, unknown>> = {
118
+ type: 'object',
119
+ properties: {
120
+ message: { type: 'string' },
121
+ session: { type: 'string' },
122
+ from: { type: 'string' },
123
+ text: { type: 'string' },
124
+ status: { type: 'string', enum: ['accepted', 'delivered', 'answered'] },
125
+ answer: { type: 'string' },
126
+ turn: { type: ['string', 'null'] },
127
+ answeredWith: { type: 'array', items: { type: 'string' } },
128
+ sentAt: { type: 'number' },
129
+ deliveredAt: { type: 'number' },
130
+ answeredAt: { type: 'number' },
131
+ },
132
+ required: ['message', 'session', 'from', 'text', 'status', 'turn', 'answeredWith', 'sentAt'],
133
+ };
134
+
135
+ const MESSAGE_SENT_OUTPUT: Readonly<Record<string, unknown>> = {
136
+ type: 'object',
137
+ properties: {
138
+ message: { type: 'string' },
139
+ status: { type: 'string', enum: ['accepted', 'delivered', 'answered'] },
140
+ },
141
+ required: ['message', 'status'],
142
+ };
143
+
144
+ const SPAWN_OPTION_OUTPUT: Readonly<Record<string, unknown>> = {
145
+ type: 'object',
146
+ properties: {
147
+ supported: { type: 'boolean' },
148
+ available: { type: 'boolean' },
149
+ values: { type: ['array', 'null'], items: { type: 'string' } },
150
+ examples: {
151
+ type: 'array',
152
+ items: {
153
+ type: 'object',
154
+ properties: { value: { type: 'string' }, resolvesTo: { type: ['string', 'null'] } },
155
+ required: ['value', 'resolvesTo'],
156
+ },
157
+ },
158
+ default: { type: ['string', 'null'] },
159
+ backendEffect: { type: ['string', 'null'], enum: ['applied', 'unverified', null] },
160
+ note: { type: ['string', 'null'] },
161
+ },
162
+ required: ['supported', 'available', 'values', 'examples', 'default', 'backendEffect', 'note'],
163
+ };
164
+
165
+ const AGENTS_OUTPUT: Readonly<Record<string, unknown>> = {
166
+ type: 'object',
167
+ properties: {
168
+ daemon: {
169
+ type: 'object',
170
+ properties: {
171
+ hostname: { type: 'string' },
172
+ platform: { type: 'string' },
173
+ arch: { type: 'string' },
174
+ build: { type: 'string' },
175
+ },
176
+ required: ['hostname', 'platform', 'arch', 'build'],
177
+ },
178
+ agents: {
179
+ type: 'array',
180
+ items: {
181
+ type: 'object',
182
+ properties: {
183
+ id: { type: 'string' },
184
+ label: { type: 'string' },
185
+ kind: { type: 'string' },
186
+ installed: { type: 'boolean' },
187
+ capabilities: {
188
+ type: 'object',
189
+ properties: {
190
+ spawn: { type: 'boolean' },
191
+ readTranscript: { type: 'boolean' },
192
+ message: { type: 'boolean' },
193
+ attach: { type: 'boolean' },
194
+ screen: { type: 'boolean' },
195
+ input: { type: 'boolean' },
196
+ },
197
+ required: ['spawn', 'readTranscript', 'message', 'attach', 'screen', 'input'],
198
+ },
199
+ models: { type: ['object', 'null'], additionalProperties: { type: 'string' } },
200
+ spawnOptions: {
201
+ type: 'object',
202
+ properties: { model: SPAWN_OPTION_OUTPUT, effort: SPAWN_OPTION_OUTPUT },
203
+ required: ['model', 'effort'],
204
+ },
205
+ },
206
+ required: ['id', 'label', 'kind', 'installed', 'capabilities', 'models', 'spawnOptions'],
207
+ },
208
+ },
209
+ targets: {
210
+ type: 'array',
211
+ items: {
212
+ type: 'object',
213
+ properties: {
214
+ id: { type: 'string' },
215
+ provider: { type: 'string' },
216
+ identity: { type: 'string' },
217
+ available: { type: 'boolean' },
218
+ default: { type: 'boolean' },
219
+ capabilities: {
220
+ type: 'object',
221
+ additionalProperties: { type: 'boolean' },
222
+ },
223
+ },
224
+ required: ['id', 'provider', 'identity', 'available', 'default', 'capabilities'],
225
+ },
226
+ },
227
+ spawnDefaults: {
228
+ type: 'object',
229
+ properties: { agent: { type: 'string' }, target: { type: ['string', 'null'] } },
230
+ required: ['agent', 'target'],
231
+ },
232
+ configRevision: { type: 'string' },
233
+ targetErrors: {
234
+ type: 'array',
235
+ items: {
236
+ type: 'object',
237
+ properties: {
238
+ scope: { type: 'string', enum: ['config', 'targets', 'target', 'defaultTarget'] },
239
+ target: { type: 'string' },
240
+ problem: { type: 'string' },
241
+ path: { type: 'string' },
242
+ detail: { type: 'string' },
243
+ },
244
+ required: ['scope', 'problem'],
245
+ },
246
+ },
247
+ },
248
+ required: ['daemon', 'agents'],
249
+ };
250
+
251
+ const EVENTS_OUTPUT: Readonly<Record<string, unknown>> = {
252
+ type: 'object',
253
+ properties: {
254
+ events: {
255
+ type: 'array',
256
+ items: {
257
+ type: 'object',
258
+ properties: {
259
+ cursor: { type: 'string' },
260
+ at: { type: 'number' },
261
+ session: { type: 'string' },
262
+ name: { type: ['string', 'null'] },
263
+ kind: { type: 'string' },
264
+ detail: { type: ['string', 'null'] },
265
+ message: { type: 'string' },
266
+ label: { type: 'string' },
267
+ },
268
+ required: ['cursor', 'at', 'session', 'name', 'kind', 'detail'],
269
+ },
270
+ },
271
+ cursor: { type: 'string' },
272
+ more: { type: 'boolean' },
273
+ },
274
+ required: ['events', 'cursor', 'more'],
275
+ };
276
+
85
277
  interface MCPToolAnnotations {
86
278
  readonly readOnlyHint: boolean;
87
279
  readonly destructiveHint: boolean;
@@ -92,7 +284,19 @@ interface MCPToolDefinition {
92
284
  readonly name: string;
93
285
  readonly description: string;
94
286
  readonly inputSchema: Readonly<Record<string, unknown>>;
287
+
288
+ // The shape of the tool's structured result, for the tools that declare one.
289
+ readonly outputSchema?: Readonly<Record<string, unknown>>;
95
290
  readonly annotations: MCPToolAnnotations;
291
+
292
+ // What the connected daemon has to announce for the tool to be listed at
293
+ // all, for its output schema to be declared, and for each listed input
294
+ // property to be offered. An older daemon gets the tool without them.
295
+ readonly requires?: {
296
+ readonly tool?: DaemonFeature;
297
+ readonly output?: DaemonFeature;
298
+ readonly inputs?: Readonly<Record<string, DaemonFeature>>;
299
+ };
96
300
  readonly scope: GrantScope;
97
301
  }
98
302
 
@@ -141,9 +345,16 @@ export const MCP_TOOLS: readonly MCPToolDefinition[] = [
141
345
  name: 'atc_session_spawn',
142
346
  annotations: AGENT_FACING,
143
347
  scope: 'spawn',
144
- description:
145
- 'Spawn a new session in a directory. Optional agent is an agent id the daemon has registered, such as claude, grok, or codex; omitted agent is always Claude, never the TUI last-used value. An unregistered id is rejected. Called from inside an atc session, the new session is a sub-session of the caller unless detached is true. Returns the new session descriptor. Give it a prompt to start it working immediately.',
348
+ description: buildSpawnDescriptions(null).tool,
146
349
  inputSchema: SPAWN_INPUT,
350
+ requires: {
351
+ inputs: {
352
+ model: 'spawn.options',
353
+ effort: 'spawn.options',
354
+ idempotencyKey: 'spawn.idempotency',
355
+ target: 'spawn.target',
356
+ },
357
+ },
147
358
  },
148
359
  {
149
360
  name: 'atc_session_input',
@@ -215,6 +426,16 @@ export const MCP_TOOLS: readonly MCPToolDefinition[] = [
215
426
  description: 'List directories sessions were previously spawned from, most recent first.',
216
427
  inputSchema: NO_INPUT,
217
428
  },
429
+ {
430
+ name: 'atc_agents_list',
431
+ annotations: READ_ONLY,
432
+ scope: 'read',
433
+ description:
434
+ "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), models (the model names the config sets for it, or null), and spawnOptions when the daemon supports spawn options. spawnOptions holds model and effort, each with supported (whether atc passes it to the agent CLI), available (whether a spawn on this host can pass it now), values (the accepted set, or null for any alias or model name), examples (each with the provider model it resolves to, when the config maps one), default (the configured value, or null for the CLI's own), backendEffect (applied, or unverified when the backend may ignore it), and a note. atc_session_spawn accepts exactly the available options. When the daemon supports targets, it also returns targets (each with its id, provider kind, identity, available, default, and capabilities), spawnDefaults (the agent and target a spawn without either runs with; a null target means such a spawn is refused), configRevision (a digest that changes whenever the target config does), and targetErrors (config problems that leave a target, or every target, unusable; a config file that exists but cannot be read or parsed is scope config, problem config_malformed or config_unreadable, with its path and detail, and refuses every spawn, local included). It never includes credentials, environment values, or endpoints, and holds nothing about which plans or subscriptions an agent's account has.",
435
+ inputSchema: NO_INPUT,
436
+ outputSchema: AGENTS_OUTPUT,
437
+ requires: { tool: 'agents.list', output: 'spawn.options' },
438
+ },
218
439
  {
219
440
  name: 'atc_session_get',
220
441
  annotations: READ_ONLY,
@@ -236,15 +457,17 @@ export const MCP_TOOLS: readonly MCPToolDefinition[] = [
236
457
  annotations: READ_ONLY,
237
458
  scope: 'read',
238
459
  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.',
460
+ '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
461
  inputSchema: EVENTS_READ_INPUT,
462
+ outputSchema: EVENTS_OUTPUT,
463
+ requires: { output: 'events.more', inputs: { session: 'events.session' } },
241
464
  },
242
465
  {
243
466
  name: 'atc_session_message',
244
467
  annotations: AGENT_FACING,
245
468
  scope: 'message',
246
469
  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.",
470
+ "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 (capabilities.message is false in atc_agents_list), 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
471
  inputSchema: {
249
472
  type: 'object',
250
473
  properties: {
@@ -255,24 +478,22 @@ export const MCP_TOOLS: readonly MCPToolDefinition[] = [
255
478
  description:
256
479
  'Who the message is from; defaults to the calling session id, or mcp outside a session. Ignored for a remote client, whose messages are always from its own name',
257
480
  },
481
+ idempotencyKey: IDEMPOTENCY_KEY_INPUT,
258
482
  },
259
483
  required: ['session', 'text'],
260
484
  additionalProperties: false,
261
485
  },
486
+ outputSchema: MESSAGE_SENT_OUTPUT,
487
+ requires: { inputs: { idempotencyKey: 'message.idempotency' } },
262
488
  },
263
489
  {
264
490
  name: 'atc_message_get',
265
491
  annotations: READ_ONLY,
266
492
  scope: 'read',
267
493
  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
- },
494
+ '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.',
495
+ inputSchema: MESSAGE_GET_INPUT,
496
+ outputSchema: MESSAGE_OUTPUT,
497
+ requires: { output: 'message.turn', inputs: { waitMs: 'message.wait' } },
277
498
  },
278
499
  ];
@@ -0,0 +1,29 @@
1
+ import { z } from 'zod';
2
+ import { DaemonError } from '../protocol/daemon-error';
3
+
4
+ /**
5
+ * The idempotency key a spawn or message tool call may carry, as its input
6
+ * schema lists it.
7
+ */
8
+ export const IDEMPOTENCY_KEY_FIELD = z
9
+ .string()
10
+ .min(1)
11
+ .max(180)
12
+ .optional()
13
+ .describe(
14
+ 'A key, unique to this call, that makes a retry safe: retrying with the same key and arguments returns the first answer instead of acting again, and the same key with different arguments is refused as idempotency_conflict. A call interrupted mid-way is refused as outcome_unknown, with the id it acted under in data.effectRef. At most 180 characters',
15
+ );
16
+
17
+ /**
18
+ * Reads a tool call's idempotency key, refusing one outside the input
19
+ * schema as `bad_args` before the call reaches the daemon.
20
+ */
21
+ export function parseIdempotencyKey(value: unknown): string | undefined {
22
+ const parsed = IDEMPOTENCY_KEY_FIELD.safeParse(value);
23
+
24
+ if (!parsed.success) {
25
+ throw new DaemonError('bad_args', 'idempotencyKey must be a string of 1 to 180 characters');
26
+ }
27
+
28
+ return parsed.data;
29
+ }
@@ -1,9 +1,14 @@
1
+ import { randomUUID } from 'node:crypto';
1
2
  import { DaemonClient } from '../client/daemon-client';
3
+ import type { DaemonFeature } from '../protocol/daemon-features';
4
+ import { parseDaemonFeatures } from '../protocol/parse-daemon-features';
5
+ import { requireDaemonFeatures } from './require-daemon-features';
2
6
  import type { FleetCaller } from './types';
3
7
 
4
8
  // The daemon requests the read-only tools send. Each only reads, so running
5
9
  // one twice is harmless.
6
10
  const RETRYABLE_METHODS: ReadonlySet<string> = new Set([
11
+ 'agents.list',
7
12
  'dirs.list',
8
13
  'events.read',
9
14
  'message.get',
@@ -14,19 +19,35 @@ const RETRYABLE_METHODS: ReadonlySet<string> = new Set([
14
19
  'session.screen',
15
20
  ]);
16
21
 
22
+ // The effectful requests a daemon takes under an idempotency key, and the
23
+ // feature it announces when it does. Under a key, a retry replays the first
24
+ // answer instead of running the effect again.
25
+ const KEYED_METHODS: ReadonlyMap<string, DaemonFeature> = new Map<string, DaemonFeature>([
26
+ ['session.spawn', 'spawn.idempotency'],
27
+ ['session.message', 'message.idempotency'],
28
+ ]);
29
+
30
+ // One handshaken connection and the features its daemon announced.
31
+ interface DaemonConnection {
32
+ readonly client: DaemonClient;
33
+ readonly features: ReadonlySet<DaemonFeature>;
34
+ }
35
+
17
36
  /**
18
37
  * A daemon caller that survives a daemon restart: once the connection ends,
19
38
  * the next request opens and handshakes a fresh one. A request that was in
20
- * flight when the connection ended is retried once on a fresh connection only
21
- * when it is read-only; any other fails, because a spawn or a message must not
22
- * run twice.
39
+ * flight when the connection ended is retried once on a fresh connection when
40
+ * it is read-only, or when it is a spawn or a message the daemon takes under an
41
+ * idempotency key: the request keeps its key, minted here when the caller
42
+ * passed none, so the daemon runs it at most once. Any other request fails,
43
+ * because a spawn or a message must not run twice.
23
44
  */
24
45
  export class ReconnectingCaller implements FleetCaller {
25
46
  private readonly socketPath: string;
26
47
 
27
48
  private readonly build: string;
28
49
 
29
- private client: Promise<DaemonClient> | null = null;
50
+ private client: Promise<DaemonConnection> | null = null;
30
51
 
31
52
  private readonly closed = new WeakSet<DaemonClient>();
32
53
 
@@ -35,25 +56,50 @@ export class ReconnectingCaller implements FleetCaller {
35
56
  this.build = build;
36
57
  }
37
58
 
59
+ // The required features are checked against each connection right before
60
+ // the request goes out on it, the retry's fresh connection included, since
61
+ // a restart can put an older daemon behind the same socket.
38
62
  async sendRequest(
39
63
  m: string,
40
64
  p?: Readonly<Record<string, unknown>>,
65
+ required: readonly DaemonFeature[] = [],
66
+ principal?: string,
41
67
  ): Promise<Readonly<Record<string, unknown>>> {
42
- const client = await this.openClient();
68
+ const opened = await this.openClient();
69
+
70
+ requireDaemonFeatures(opened.features, required);
71
+
72
+ const keyFeature = KEYED_METHODS.get(m);
73
+ const keyed = keyFeature !== undefined && opened.features.has(keyFeature);
74
+ const params = keyed ? buildKeyedParams(p) : p;
43
75
 
44
76
  try {
45
- return await client.sendRequest(m, p);
77
+ return await opened.client.sendRequest(m, params, principal);
46
78
  } catch (error) {
47
- if (!this.closed.has(client) || !RETRYABLE_METHODS.has(m)) {
79
+ if (!this.closed.has(opened.client) || !(keyed || RETRYABLE_METHODS.has(m))) {
48
80
  throw error;
49
81
  }
50
82
 
51
83
  const fresh = await this.openClient();
52
84
 
53
- return fresh.sendRequest(m, p);
85
+ // The fresh daemon has to take the key too, or the retry could run the
86
+ // effect a second time.
87
+ const retryRequired = keyed ? [...required, keyFeature] : required;
88
+
89
+ requireDaemonFeatures(fresh.features, retryRequired);
90
+
91
+ return fresh.client.sendRequest(m, params, principal);
54
92
  }
55
93
  }
56
94
 
95
+ // The features of the daemon the next request reaches, read from the
96
+ // handshake of the connection it rides.
97
+ async readFeatures(): Promise<ReadonlySet<DaemonFeature>> {
98
+ const opened = await this.openClient();
99
+
100
+ return opened.features;
101
+ }
102
+
57
103
  async stop(): Promise<void> {
58
104
  const current = this.client;
59
105
 
@@ -64,17 +110,19 @@ export class ReconnectingCaller implements FleetCaller {
64
110
  }
65
111
 
66
112
  try {
67
- const client = await current;
113
+ const opened = await current;
68
114
 
69
- client.stop();
115
+ opened.client.stop();
70
116
  } catch {
71
117
  // A connection that never opened has nothing to close.
72
118
  }
73
119
  }
74
120
 
75
- private openClient(): Promise<DaemonClient> {
121
+ private openClient(): Promise<DaemonConnection> {
76
122
  if (this.client === null) {
77
- const opening: Promise<DaemonClient> = this.openFreshClient(() => this.client === opening);
123
+ const opening: Promise<DaemonConnection> = this.openFreshClient(
124
+ () => this.client === opening,
125
+ );
78
126
 
79
127
  this.client = opening;
80
128
  }
@@ -85,7 +133,7 @@ export class ReconnectingCaller implements FleetCaller {
85
133
  // isCurrent returns true while this connection is still the one later requests
86
134
  // reuse: a connection that ends after a newer one replaced it must leave
87
135
  // the newer one in place.
88
- private async openFreshClient(isCurrent: () => boolean): Promise<DaemonClient> {
136
+ private async openFreshClient(isCurrent: () => boolean): Promise<DaemonConnection> {
89
137
  const resetClient = () => {
90
138
  if (isCurrent()) {
91
139
  this.client = null;
@@ -107,8 +155,10 @@ export class ReconnectingCaller implements FleetCaller {
107
155
  resetClient();
108
156
  };
109
157
 
158
+ let hello: Readonly<Record<string, unknown>>;
159
+
110
160
  try {
111
- await client.sendHello(this.build);
161
+ hello = await client.sendHello(this.build);
112
162
  } catch (error) {
113
163
  client.stop();
114
164
 
@@ -116,6 +166,18 @@ export class ReconnectingCaller implements FleetCaller {
116
166
  throw error;
117
167
  }
118
168
 
119
- return client;
169
+ return { client, features: parseDaemonFeatures(hello) };
120
170
  }
121
171
  }
172
+
173
+ // The request's params with its idempotency key, minting one when the caller
174
+ // passed none.
175
+ function buildKeyedParams(
176
+ p: Readonly<Record<string, unknown>> | undefined,
177
+ ): Readonly<Record<string, unknown>> {
178
+ if (typeof p?.['idempotencyKey'] === 'string') {
179
+ return p;
180
+ }
181
+
182
+ return { ...p, idempotencyKey: randomUUID() };
183
+ }
@@ -0,0 +1,40 @@
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
+ 'daemon.id': "the daemon's persisted identity",
8
+ 'events.more': "atc_events_read's more flag",
9
+ 'events.session': "atc_events_read's session filter",
10
+ 'message.idempotency': "atc_session_message's idempotencyKey",
11
+ 'message.turn': "atc_message_get's turn and answeredWith",
12
+ 'message.wait': "atc_message_get's waitMs",
13
+ 'session.locator': "a session's locator",
14
+ 'spawn.idempotency': "atc_session_spawn's idempotencyKey",
15
+ 'spawn.options': "atc_session_spawn's model and effort",
16
+ 'spawn.target': "atc_session_spawn's target",
17
+ 'request.principal': 'the target limits of a remote MCP client',
18
+ };
19
+
20
+ /**
21
+ * Throws a `daemon_outdated` error when the daemon a request is about to
22
+ * reach lacks a feature the request depends on, rather than letting a daemon
23
+ * that would ignore the option answer as if it had honoured it. atc never
24
+ * restarts the daemon itself: a restart is the operator's call, because it
25
+ * respawns every session.
26
+ */
27
+ export function requireDaemonFeatures(
28
+ features: ReadonlySet<DaemonFeature>,
29
+ required: readonly DaemonFeature[],
30
+ ): void {
31
+ const missing = required.find((feature) => !features.has(feature));
32
+
33
+ if (missing === undefined) {
34
+ return;
35
+ }
36
+
37
+ throw new Error(
38
+ `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.`,
39
+ );
40
+ }