@zgeoff/atc 2.11.0 → 2.13.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 (119) hide show
  1. package/README.md +10 -10
  2. package/package.json +2 -1
  3. package/src/agents/agent-adapter.ts +98 -10
  4. package/src/agents/build-args-without-flags.ts +25 -0
  5. package/src/agents/build-atc-bridge-files.ts +13 -0
  6. package/src/agents/build-claude-override-args.ts +26 -0
  7. package/src/agents/build-claude-query-options.ts +72 -0
  8. package/src/agents/build-cli-command.ts +7 -5
  9. package/src/{daemon → agents}/build-headless-env.ts +12 -6
  10. package/src/agents/build-hook-settings.ts +8 -2
  11. package/src/agents/build-restore-mode-args.ts +23 -0
  12. package/src/agents/claude-adapter.ts +112 -18
  13. package/src/agents/claude-effort-levels.ts +5 -0
  14. package/src/agents/codex-adapter.ts +48 -2
  15. package/src/agents/find-claude-permission-mode.ts +24 -0
  16. package/src/agents/find-flag-value.ts +27 -0
  17. package/src/agents/gateway-adapter.ts +83 -13
  18. package/src/agents/grok-adapter.ts +24 -1
  19. package/src/agents/make-claude-headless-runner.ts +46 -0
  20. package/src/agents/plan-pasted-line-input.ts +22 -0
  21. package/src/agents/plan-typed-line-input.ts +8 -0
  22. package/src/agents/resolve-claude-permission-mode.ts +13 -0
  23. package/src/{daemon/start-headless-run.ts → agents/start-claude-headless-run.ts} +6 -42
  24. package/src/agents/write-atc-bridge.ts +2 -6
  25. package/src/cli.ts +23 -9
  26. package/src/client/daemon-client.ts +14 -2
  27. package/src/client/index.ts +50 -1
  28. package/src/client/spawn-picker.ts +27 -4
  29. package/src/client/ui.ts +8 -0
  30. package/src/daemon/build-agent-list.ts +37 -1
  31. package/src/daemon/build-config-revision.ts +27 -0
  32. package/src/daemon/build-execution-targets.ts +73 -0
  33. package/src/daemon/build-fleet-events.ts +2 -1
  34. package/src/daemon/build-imp-provider.ts +68 -0
  35. package/src/daemon/build-payload-hash.ts +31 -0
  36. package/src/daemon/build-report-trail-entry.ts +4 -2
  37. package/src/daemon/build-scoped-context.ts +235 -0
  38. package/src/daemon/build-session-lifecycle.ts +52 -0
  39. package/src/daemon/build-tar-archive.ts +85 -0
  40. package/src/daemon/build-target-access.ts +33 -0
  41. package/src/daemon/build-target-forbidden-error.ts +13 -0
  42. package/src/daemon/build-target-identity.ts +22 -0
  43. package/src/daemon/build-target-list.ts +50 -0
  44. package/src/daemon/daemon-connection.ts +425 -96
  45. package/src/daemon/daemon.ts +730 -96
  46. package/src/daemon/effect-remains-error.ts +13 -0
  47. package/src/daemon/execution-provider.ts +208 -0
  48. package/src/daemon/find-execution-refusal.ts +104 -0
  49. package/src/daemon/hooks.ts +5 -17
  50. package/src/daemon/idempotency-ledger.ts +164 -0
  51. package/src/daemon/imp-client-port.ts +343 -0
  52. package/src/daemon/imp-harness.ts +618 -0
  53. package/src/daemon/imp-port-error.ts +18 -0
  54. package/src/daemon/imp-port.ts +246 -0
  55. package/src/daemon/imp-provider.ts +601 -0
  56. package/src/daemon/is-binding-current.ts +43 -0
  57. package/src/daemon/local-pty-provider.ts +142 -0
  58. package/src/daemon/materialize-workspace.ts +571 -0
  59. package/src/daemon/mint-session-id.ts +5 -5
  60. package/src/daemon/parse-hook-line.ts +31 -0
  61. package/src/daemon/parse-spawn-overrides.ts +94 -0
  62. package/src/daemon/permission-registry.ts +14 -4
  63. package/src/daemon/pick-session-state.ts +15 -0
  64. package/src/daemon/restore-fleet.ts +103 -38
  65. package/src/daemon/screen-model.ts +7 -0
  66. package/src/daemon/session-runtime.ts +10 -0
  67. package/src/daemon/sessions.ts +835 -105
  68. package/src/daemon/start-headless-turn.ts +12 -3
  69. package/src/daemon/start-session-bridge.ts +294 -0
  70. package/src/daemon/target-access.ts +36 -0
  71. package/src/mcp/answer-mcp-request.ts +12 -4
  72. package/src/mcp/answer-rpc-request.ts +28 -1
  73. package/src/mcp/build-principal-caller.ts +15 -0
  74. package/src/mcp/build-spawn-descriptions.ts +45 -0
  75. package/src/mcp/build-tool-list.ts +32 -4
  76. package/src/mcp/mcp-tools.ts +107 -10
  77. package/src/mcp/parse-idempotency-key.ts +29 -0
  78. package/src/mcp/reconnecting-caller.ts +39 -7
  79. package/src/mcp/require-daemon-features.ts +10 -0
  80. package/src/mcp/run-tool.ts +87 -16
  81. package/src/mcp/types.ts +2 -0
  82. package/src/protocol/daemon-error.ts +5 -1
  83. package/src/protocol/daemon-features.ts +35 -0
  84. package/src/protocol/protocol.ts +67 -11
  85. package/src/protocol/request-param-schemas.ts +126 -10
  86. package/src/report.ts +37 -2
  87. package/src/run-bridge-tap.ts +241 -0
  88. package/src/shared/agent-session-id.ts +1 -1
  89. package/src/shared/collect-clean-env.ts +9 -1
  90. package/src/shared/collect-principals.ts +51 -0
  91. package/src/shared/collect-targets.ts +144 -0
  92. package/src/shared/config.ts +139 -14
  93. package/src/shared/daemon-id.ts +8 -0
  94. package/src/shared/format-json-kind.ts +21 -0
  95. package/src/shared/open-bridge-socket.ts +86 -0
  96. package/src/shared/send-bridge-request.ts +47 -0
  97. package/src/shared/sort-json-keys.ts +19 -0
  98. package/src/shared/to-daemon-id.ts +11 -0
  99. package/src/statusline.ts +34 -3
  100. package/src/store/fleet-entry.ts +61 -9
  101. package/src/store/idempotency-record.ts +48 -0
  102. package/src/store/message-owner.ts +1 -1
  103. package/src/store/run-migrations.ts +330 -5
  104. package/src/store/state-store.ts +607 -44
  105. package/src/store/trail-entry.ts +4 -0
  106. package/src/store/workspace-materialization.ts +70 -0
  107. package/src/tap.ts +11 -0
  108. package/src/workspace/check-url-credentials.ts +59 -0
  109. package/src/workspace/check-workspace-completeness.ts +90 -0
  110. package/src/workspace/create-workspace-clone.ts +251 -0
  111. package/src/workspace/normalize-git-url.ts +59 -0
  112. package/src/workspace/read-workspace-tar.ts +38 -0
  113. package/src/workspace/repository-env-vars.ts +22 -0
  114. package/src/workspace/resolve-path-source.ts +170 -0
  115. package/src/workspace/run-git.ts +87 -0
  116. package/src/workspace/sanitize-workspace-clone.ts +146 -0
  117. package/src/workspace/workspace-provenance.ts +11 -0
  118. package/src/workspace/workspace-source.ts +8 -0
  119. /package/src/{daemon → agents}/resolve-headless-executable.ts +0 -0
@@ -2,6 +2,8 @@ import { z } from 'zod';
2
2
  import type { DaemonFeature } from '../protocol/daemon-features';
3
3
  import { REQUEST_PARAM_SCHEMAS } from '../protocol/request-param-schemas';
4
4
  import type { GrantScope } from '../shared/grant-scope';
5
+ import { buildSpawnDescriptions } from './build-spawn-descriptions';
6
+ import { IDEMPOTENCY_KEY_FIELD } from './parse-idempotency-key';
5
7
 
6
8
  const NO_INPUT: Readonly<Record<string, unknown>> = z.toJSONSchema(z.strictObject({}));
7
9
 
@@ -17,14 +19,31 @@ const SESSION_ID_BASE = z.object({
17
19
 
18
20
  const SESSION_INPUT: Readonly<Record<string, unknown>> = z.toJSONSchema(SESSION_ID_BASE.strict());
19
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
+ });
20
29
 
21
30
  const SPAWN_INPUT: Readonly<Record<string, unknown>> = z.toJSONSchema(
22
31
  z.strictObject({
23
32
  cwd: SPAWN_SCHEMA.shape.cwd.describe('Absolute path of the working directory'),
24
33
  name: SPAWN_SCHEMA.shape.name.describe('Session name; defaults to the directory basename'),
25
34
  prompt: SPAWN_SCHEMA.shape.prompt.describe('First message for the session'),
26
- agent: SPAWN_SCHEMA.shape.agent.describe(
27
- '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.',
44
+ ),
45
+ workspace: SPAWN_SCHEMA.shape.workspace.describe(
46
+ "Where the session's working directory comes from. Omit it to run the session in cwd as it stands. With it, atc materializes a clean checkout into cwd on the target, which must not exist yet: {kind:'path', path, allowDirty?} checks out the pushed HEAD of a git checkout on the atc host, refusing uncommitted changes unless allowDirty is 'warn'; {kind:'git', url, ref or sha, credentialRef?} checks out a branch, tag, or full commit of a repository, with credentialRef {kind:'env', name} naming the atc daemon's environment variable that holds its token. A directory outside git runs in place only on a target on the atc host itself (provider local-pty), with cwd equal to its path. Submodules and Git LFS are refused, and so is a URL that carries a credential.",
28
47
  ),
29
48
  detached: z
30
49
  .boolean()
@@ -32,6 +51,7 @@ const SPAWN_INPUT: Readonly<Record<string, unknown>> = z.toJSONSchema(
32
51
  .describe(
33
52
  '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.',
34
53
  ),
54
+ idempotencyKey: IDEMPOTENCY_KEY_FIELD,
35
55
  }),
36
56
  { io: 'input' },
37
57
  );
@@ -124,6 +144,27 @@ const MESSAGE_SENT_OUTPUT: Readonly<Record<string, unknown>> = {
124
144
  required: ['message', 'status'],
125
145
  };
126
146
 
147
+ const SPAWN_OPTION_OUTPUT: Readonly<Record<string, unknown>> = {
148
+ type: 'object',
149
+ properties: {
150
+ supported: { type: 'boolean' },
151
+ available: { type: 'boolean' },
152
+ values: { type: ['array', 'null'], items: { type: 'string' } },
153
+ examples: {
154
+ type: 'array',
155
+ items: {
156
+ type: 'object',
157
+ properties: { value: { type: 'string' }, resolvesTo: { type: ['string', 'null'] } },
158
+ required: ['value', 'resolvesTo'],
159
+ },
160
+ },
161
+ default: { type: ['string', 'null'] },
162
+ backendEffect: { type: ['string', 'null'], enum: ['applied', 'unverified', null] },
163
+ note: { type: ['string', 'null'] },
164
+ },
165
+ required: ['supported', 'available', 'values', 'examples', 'default', 'backendEffect', 'note'],
166
+ };
167
+
127
168
  const AGENTS_OUTPUT: Readonly<Record<string, unknown>> = {
128
169
  type: 'object',
129
170
  properties: {
@@ -159,8 +200,51 @@ const AGENTS_OUTPUT: Readonly<Record<string, unknown>> = {
159
200
  required: ['spawn', 'readTranscript', 'message', 'attach', 'screen', 'input'],
160
201
  },
161
202
  models: { type: ['object', 'null'], additionalProperties: { type: 'string' } },
203
+ spawnOptions: {
204
+ type: 'object',
205
+ properties: { model: SPAWN_OPTION_OUTPUT, effort: SPAWN_OPTION_OUTPUT },
206
+ required: ['model', 'effort'],
207
+ },
208
+ },
209
+ required: ['id', 'label', 'kind', 'installed', 'capabilities', 'models', 'spawnOptions'],
210
+ },
211
+ },
212
+ targets: {
213
+ type: 'array',
214
+ items: {
215
+ type: 'object',
216
+ properties: {
217
+ id: { type: 'string' },
218
+ provider: { type: 'string' },
219
+ identity: { type: 'string' },
220
+ available: { type: 'boolean' },
221
+ default: { type: 'boolean' },
222
+ capabilities: {
223
+ type: 'object',
224
+ additionalProperties: { type: 'boolean' },
225
+ },
162
226
  },
163
- required: ['id', 'label', 'kind', 'installed', 'capabilities', 'models'],
227
+ required: ['id', 'provider', 'identity', 'available', 'default', 'capabilities'],
228
+ },
229
+ },
230
+ spawnDefaults: {
231
+ type: 'object',
232
+ properties: { agent: { type: 'string' }, target: { type: ['string', 'null'] } },
233
+ required: ['agent', 'target'],
234
+ },
235
+ configRevision: { type: 'string' },
236
+ targetErrors: {
237
+ type: 'array',
238
+ items: {
239
+ type: 'object',
240
+ properties: {
241
+ scope: { type: 'string', enum: ['config', 'targets', 'target', 'defaultTarget'] },
242
+ target: { type: 'string' },
243
+ problem: { type: 'string' },
244
+ path: { type: 'string' },
245
+ detail: { type: 'string' },
246
+ },
247
+ required: ['scope', 'problem'],
164
248
  },
165
249
  },
166
250
  },
@@ -264,21 +348,32 @@ export const MCP_TOOLS: readonly MCPToolDefinition[] = [
264
348
  name: 'atc_session_spawn',
265
349
  annotations: AGENT_FACING,
266
350
  scope: 'spawn',
267
- description:
268
- '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.',
351
+ description: buildSpawnDescriptions(null).tool,
269
352
  inputSchema: SPAWN_INPUT,
353
+ requires: {
354
+ inputs: {
355
+ model: 'spawn.options',
356
+ effort: 'spawn.options',
357
+ idempotencyKey: 'spawn.idempotency',
358
+ target: 'spawn.target',
359
+ workspace: 'spawn.workspace',
360
+ },
361
+ },
270
362
  },
271
363
  {
272
364
  name: 'atc_session_input',
273
365
  annotations: AGENT_FACING_DESTRUCTIVE,
274
366
  scope: 'spawn',
275
367
  description:
276
- 'Type a line of text into a running session, as if the operator typed it and pressed enter. Use it to answer a session that is waiting on input.',
368
+ "Type a line of text into a running session and submit it, as if the operator typed it and pressed enter. atc submits the line the way the session's agent accepts one. Use it to answer a session that is waiting on input. A result of sent means atc wrote the line and its submit key to the session; it does not confirm that the agent took the line or answered it. Read the session's screen or events for that. The tool sends no raw keystrokes.",
277
369
  inputSchema: {
278
370
  type: 'object',
279
371
  properties: {
280
372
  session: { type: 'string', description: 'The atc session id' },
281
- text: { type: 'string', description: 'The line to type; a newline is appended' },
373
+ text: {
374
+ type: 'string',
375
+ description: "The line to submit; atc adds the submit key the session's agent expects",
376
+ },
282
377
  },
283
378
  required: ['session', 'text'],
284
379
  additionalProperties: false,
@@ -343,10 +438,10 @@ export const MCP_TOOLS: readonly MCPToolDefinition[] = [
343
438
  annotations: READ_ONLY,
344
439
  scope: 'read',
345
440
  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.",
441
+ "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.",
347
442
  inputSchema: NO_INPUT,
348
443
  outputSchema: AGENTS_OUTPUT,
349
- requires: { tool: 'agents.list' },
444
+ requires: { tool: 'agents.list', output: 'spawn.options' },
350
445
  },
351
446
  {
352
447
  name: 'atc_session_get',
@@ -379,7 +474,7 @@ export const MCP_TOOLS: readonly MCPToolDefinition[] = [
379
474
  annotations: AGENT_FACING,
380
475
  scope: 'message',
381
476
  description:
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.",
477
+ "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.",
383
478
  inputSchema: {
384
479
  type: 'object',
385
480
  properties: {
@@ -390,11 +485,13 @@ export const MCP_TOOLS: readonly MCPToolDefinition[] = [
390
485
  description:
391
486
  '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',
392
487
  },
488
+ idempotencyKey: IDEMPOTENCY_KEY_INPUT,
393
489
  },
394
490
  required: ['session', 'text'],
395
491
  additionalProperties: false,
396
492
  },
397
493
  outputSchema: MESSAGE_SENT_OUTPUT,
494
+ requires: { inputs: { idempotencyKey: 'message.idempotency' } },
398
495
  },
399
496
  {
400
497
  name: 'atc_message_get',
@@ -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,3 +1,4 @@
1
+ import { randomUUID } from 'node:crypto';
1
2
  import { DaemonClient } from '../client/daemon-client';
2
3
  import type { DaemonFeature } from '../protocol/daemon-features';
3
4
  import { parseDaemonFeatures } from '../protocol/parse-daemon-features';
@@ -18,6 +19,14 @@ const RETRYABLE_METHODS: ReadonlySet<string> = new Set([
18
19
  'session.screen',
19
20
  ]);
20
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
+
21
30
  // One handshaken connection and the features its daemon announced.
22
31
  interface DaemonConnection {
23
32
  readonly client: DaemonClient;
@@ -27,9 +36,11 @@ interface DaemonConnection {
27
36
  /**
28
37
  * A daemon caller that survives a daemon restart: once the connection ends,
29
38
  * the next request opens and handshakes a fresh one. A request that was in
30
- * flight when the connection ended is retried once on a fresh connection only
31
- * when it is read-only; any other fails, because a spawn or a message must not
32
- * 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.
33
44
  */
34
45
  export class ReconnectingCaller implements FleetCaller {
35
46
  private readonly socketPath: string;
@@ -52,23 +63,32 @@ export class ReconnectingCaller implements FleetCaller {
52
63
  m: string,
53
64
  p?: Readonly<Record<string, unknown>>,
54
65
  required: readonly DaemonFeature[] = [],
66
+ principal?: string,
55
67
  ): Promise<Readonly<Record<string, unknown>>> {
56
68
  const opened = await this.openClient();
57
69
 
58
70
  requireDaemonFeatures(opened.features, required);
59
71
 
72
+ const keyFeature = KEYED_METHODS.get(m);
73
+ const keyed = keyFeature !== undefined && opened.features.has(keyFeature);
74
+ const params = keyed ? buildKeyedParams(p) : p;
75
+
60
76
  try {
61
- return await opened.client.sendRequest(m, p);
77
+ return await opened.client.sendRequest(m, params, principal);
62
78
  } catch (error) {
63
- if (!this.closed.has(opened.client) || !RETRYABLE_METHODS.has(m)) {
79
+ if (!this.closed.has(opened.client) || !(keyed || RETRYABLE_METHODS.has(m))) {
64
80
  throw error;
65
81
  }
66
82
 
67
83
  const fresh = await this.openClient();
68
84
 
69
- requireDaemonFeatures(fresh.features, required);
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);
70
90
 
71
- return fresh.client.sendRequest(m, p);
91
+ return fresh.client.sendRequest(m, params, principal);
72
92
  }
73
93
  }
74
94
 
@@ -149,3 +169,15 @@ export class ReconnectingCaller implements FleetCaller {
149
169
  return { client, features: parseDaemonFeatures(hello) };
150
170
  }
151
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
+ }
@@ -4,10 +4,20 @@ import type { DaemonFeature } from '../protocol/daemon-features';
4
4
  // states it.
5
5
  const FEATURE_USES: Readonly<Record<DaemonFeature, string>> = {
6
6
  'agents.list': 'atc_agents_list',
7
+ 'daemon.id': "the daemon's persisted identity",
7
8
  'events.more': "atc_events_read's more flag",
8
9
  'events.session': "atc_events_read's session filter",
10
+ 'message.idempotency': "atc_session_message's idempotencyKey",
9
11
  'message.turn': "atc_message_get's turn and answeredWith",
10
12
  'message.wait': "atc_message_get's waitMs",
13
+ 'session.forget': 'session.forget',
14
+ 'session.locator': "a session's locator",
15
+ 'session.submit': 'atc_session_input',
16
+ 'spawn.idempotency': "atc_session_spawn's idempotencyKey",
17
+ 'spawn.options': "atc_session_spawn's model and effort",
18
+ 'spawn.target': "atc_session_spawn's target",
19
+ 'request.principal': 'the target limits of a remote MCP client',
20
+ 'spawn.workspace': "atc_session_spawn's workspace",
11
21
  };
12
22
 
13
23
  /**
@@ -1,7 +1,9 @@
1
+ import { createHash } from 'node:crypto';
1
2
  import { match } from 'ts-pattern';
2
3
  import { DaemonError } from '../protocol/daemon-error';
3
4
  import type { DaemonFeature } from '../protocol/daemon-features';
4
5
  import { isRecord } from '../shared/report';
6
+ import { parseIdempotencyKey } from './parse-idempotency-key';
5
7
  import type { FleetCaller, ToolContext } from './types';
6
8
 
7
9
  /**
@@ -33,28 +35,64 @@ export function runTool(
33
35
  .with('atc_session_spawn', async () => {
34
36
  const rawAgent = args['agent'];
35
37
  const nested = args['detached'] !== true && ctx.callerSessionID !== null;
38
+ const key = parseIdempotencyKey(args['idempotencyKey']);
36
39
 
37
40
  const params = {
38
41
  cwd: args['cwd'],
39
42
  ...(typeof args['name'] === 'string' ? { name: args['name'] } : {}),
40
43
  ...(typeof args['prompt'] === 'string' ? { prompt: args['prompt'] } : {}),
41
44
  ...(rawAgent === undefined ? {} : { agent: rawAgent }),
45
+ ...(args['model'] === undefined ? {} : { model: args['model'] }),
46
+ ...(args['effort'] === undefined ? {} : { effort: args['effort'] }),
47
+ ...(key === undefined ? {} : { idempotencyKey: key }),
48
+ ...(args['target'] === undefined ? {} : { target: args['target'] }),
49
+ ...(args['workspace'] === undefined ? {} : { workspace: args['workspace'] }),
42
50
  cols: 100,
43
51
  rows: 30,
44
52
  };
45
53
 
54
+ // A model, effort, key, target, or workspace needs a daemon that takes
55
+ // them; the check runs on every connection the spawn rides.
56
+ const optionFeatures: readonly DaemonFeature[] =
57
+ args['model'] === undefined && args['effort'] === undefined ? [] : ['spawn.options'];
58
+
59
+ const keyFeatures: readonly DaemonFeature[] = key === undefined ? [] : ['spawn.idempotency'];
60
+
61
+ const targetFeatures: readonly DaemonFeature[] =
62
+ args['target'] === undefined ? [] : ['spawn.target'];
63
+
64
+ const workspaceFeatures: readonly DaemonFeature[] =
65
+ args['workspace'] === undefined ? [] : ['spawn.workspace'];
66
+
67
+ const required = [...optionFeatures, ...keyFeatures, ...targetFeatures, ...workspaceFeatures];
68
+
46
69
  const ok =
47
70
  nested && ctx.callerSessionID !== null
48
- ? await sendNestedSpawn(caller, params, ctx.callerSessionID)
49
- : await caller.sendRequest('session.spawn', params);
71
+ ? await sendNestedSpawn(caller, params, ctx.callerSessionID, required)
72
+ : await caller.sendRequest('session.spawn', params, required);
73
+
74
+ // A spawn whose workspace left changes behind returns its warnings
75
+ // beside the session's own fields.
76
+ const warnings = ok['warnings'];
77
+
78
+ const session =
79
+ warnings === undefined || !isRecord(ok['session'])
80
+ ? ok['session']
81
+ : { ...ok['session'], warnings };
50
82
 
51
- return buildObjectResult(ok['session']);
83
+ return buildObjectResult(session);
52
84
  })
53
85
  .with('atc_session_input', async () => {
54
- await caller.sendRequest('session.input', {
55
- session: args['session'],
56
- d: `${typeof args['text'] === 'string' ? args['text'] : ''}\n`,
57
- });
86
+ // An older daemon would take the line as raw input, which some agents
87
+ // never submit.
88
+ await caller.sendRequest(
89
+ 'session.submit',
90
+ {
91
+ session: args['session'],
92
+ text: typeof args['text'] === 'string' ? args['text'] : '',
93
+ },
94
+ ['session.submit'],
95
+ );
58
96
 
59
97
  return { text: 'sent', structured: null };
60
98
  })
@@ -142,11 +180,19 @@ export function runTool(
142
180
  ? given
143
181
  : ctx.sender.name;
144
182
 
145
- const ok = await caller.sendRequest('session.message', {
146
- session: args['session'],
147
- text: args['text'],
148
- from,
149
- });
183
+ const key = parseIdempotencyKey(args['idempotencyKey']);
184
+ const required: DaemonFeature[] = key === undefined ? [] : ['message.idempotency'];
185
+
186
+ const ok = await caller.sendRequest(
187
+ 'session.message',
188
+ {
189
+ session: args['session'],
190
+ text: args['text'],
191
+ from,
192
+ ...(key === undefined ? {} : { idempotencyKey: key }),
193
+ },
194
+ required,
195
+ );
150
196
 
151
197
  return buildObjectResult(ok);
152
198
  })
@@ -177,19 +223,44 @@ function buildObjectResult(value: unknown): ToolResult {
177
223
 
178
224
  // The inherited id can point at a session another daemon hosts, or one
179
225
  // this daemon no longer lists; the spawn then lands top-level instead of
180
- // failing the tool call.
226
+ // failing the tool call. The top-level spawn has a different payload, so it
227
+ // runs under its own key, a fixed-length hash of the caller's: a retry of the
228
+ // tool call derives the same key and replays it rather than conflicting with
229
+ // the nested attempt's key, and the derived key fits the daemon's cap
230
+ // whatever the caller's length. An answer
231
+ // that holds an effect id is a keyed spawn that already ran, which a
232
+ // top-level spawn would only duplicate.
181
233
  async function sendNestedSpawn(
182
234
  caller: FleetCaller,
183
235
  params: Readonly<Record<string, unknown>>,
184
236
  parent: string,
237
+ required: readonly DaemonFeature[],
185
238
  ): Promise<Readonly<Record<string, unknown>>> {
186
239
  try {
187
- return await caller.sendRequest('session.spawn', { ...params, parent });
240
+ return await caller.sendRequest('session.spawn', { ...params, parent }, required);
188
241
  } catch (error) {
189
- if (error instanceof DaemonError && error.code === 'no_such_session') {
190
- return caller.sendRequest('session.spawn', params);
242
+ if (
243
+ error instanceof DaemonError &&
244
+ error.code === 'no_such_session' &&
245
+ error.data?.['effectRef'] === undefined
246
+ ) {
247
+ return caller.sendRequest('session.spawn', buildTopLevelParams(params), required);
191
248
  }
192
249
 
193
250
  throw error;
194
251
  }
195
252
  }
253
+
254
+ function buildTopLevelParams(
255
+ params: Readonly<Record<string, unknown>>,
256
+ ): Readonly<Record<string, unknown>> {
257
+ const key = params['idempotencyKey'];
258
+
259
+ if (typeof key !== 'string') {
260
+ return params;
261
+ }
262
+
263
+ const digest = createHash('sha256').update(key).digest('hex');
264
+
265
+ return { ...params, idempotencyKey: `top-level:${digest}` };
266
+ }
package/src/mcp/types.ts CHANGED
@@ -6,11 +6,13 @@ import type { openMCPAuth } from './open-mcp-auth';
6
6
  // features the connected daemon announced at its handshake. A request that
7
7
  // lists required features is checked against the connection it is about to
8
8
  // ride, every time it is sent, and refused unsent when that daemon lacks one.
9
+ // A request with a principal acts as that principal.
9
10
  export interface FleetCaller {
10
11
  readonly sendRequest: (
11
12
  m: string,
12
13
  p?: Readonly<Record<string, unknown>>,
13
14
  required?: readonly DaemonFeature[],
15
+ principal?: string,
14
16
  ) => Promise<Readonly<Record<string, unknown>>>;
15
17
  readonly readFeatures: () => Promise<ReadonlySet<DaemonFeature>>;
16
18
  }
@@ -7,10 +7,14 @@ import type { ErrorCode } from './protocol';
7
7
  export class DaemonError extends Error {
8
8
  readonly code: ErrorCode;
9
9
 
10
- constructor(code: ErrorCode, msg: string) {
10
+ // Structured detail the error code defines for itself, sent as `err.data`.
11
+ readonly data: Readonly<Record<string, unknown>> | undefined;
12
+
13
+ constructor(code: ErrorCode, msg: string, data?: Readonly<Record<string, unknown>>) {
11
14
  super(msg);
12
15
 
13
16
  this.code = code;
17
+ this.data = data;
14
18
  this.name = 'DaemonError';
15
19
  }
16
20
  }
@@ -18,6 +18,41 @@ export const DAEMON_FEATURES = [
18
18
 
19
19
  // `message.get` takes `waitMs`.
20
20
  'message.wait',
21
+
22
+ // `session.spawn` takes `model` and `effort`, and `agents.list` returns
23
+ // `spawnOptions`.
24
+ 'spawn.options',
25
+
26
+ // `daemon.hello` returns `daemonID`.
27
+ 'daemon.id',
28
+
29
+ // Every session descriptor holds a `locator`.
30
+ 'session.locator',
31
+
32
+ // `session.spawn` takes `idempotencyKey`.
33
+ 'spawn.idempotency',
34
+
35
+ // `session.message` takes `idempotencyKey`.
36
+ 'message.idempotency',
37
+
38
+ // `session.spawn` takes `target`, and `agents.list` returns `targets`,
39
+ // `spawnDefaults`, `configRevision`, and `targetErrors`.
40
+ 'spawn.target',
41
+
42
+ // A request takes `as`, the principal it acts as, and `daemon.hello`
43
+ // takes `principal`, the principal the whole connection acts as.
44
+ 'request.principal',
45
+
46
+ // `session.spawn` takes `workspace`, and a session descriptor holds the
47
+ // `workspace` its checkout was materialized from.
48
+ 'spawn.workspace',
49
+
50
+ // `session.forget` exists, and a kill of a session asleep on a target that
51
+ // can destroy its host answers `confirmation_required`.
52
+ 'session.forget',
53
+
54
+ // `session.submit` exists.
55
+ 'session.submit',
21
56
  ] as const;
22
57
 
23
58
  export type DaemonFeature = (typeof DAEMON_FEATURES)[number];