@zgeoff/atc 2.11.0 → 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 (76) hide show
  1. package/package.json +1 -1
  2. package/src/agents/agent-adapter.ts +51 -0
  3. package/src/agents/build-args-without-flags.ts +25 -0
  4. package/src/agents/build-claude-override-args.ts +26 -0
  5. package/src/agents/claude-adapter.ts +49 -2
  6. package/src/agents/claude-effort-levels.ts +5 -0
  7. package/src/agents/codex-adapter.ts +43 -2
  8. package/src/agents/find-flag-value.ts +27 -0
  9. package/src/agents/gateway-adapter.ts +45 -2
  10. package/src/agents/grok-adapter.ts +19 -1
  11. package/src/cli.ts +18 -0
  12. package/src/client/daemon-client.ts +14 -2
  13. package/src/client/spawn-picker.ts +27 -4
  14. package/src/daemon/build-agent-list.ts +37 -1
  15. package/src/daemon/build-config-revision.ts +27 -0
  16. package/src/daemon/build-execution-targets.ts +34 -0
  17. package/src/daemon/build-fleet-events.ts +2 -1
  18. package/src/daemon/build-payload-hash.ts +31 -0
  19. package/src/daemon/build-scoped-context.ts +232 -0
  20. package/src/daemon/build-target-access.ts +33 -0
  21. package/src/daemon/build-target-forbidden-error.ts +13 -0
  22. package/src/daemon/build-target-identity.ts +22 -0
  23. package/src/daemon/build-target-list.ts +50 -0
  24. package/src/daemon/daemon-connection.ts +331 -95
  25. package/src/daemon/daemon.ts +452 -75
  26. package/src/daemon/effect-remains-error.ts +13 -0
  27. package/src/daemon/execution-provider.ts +103 -0
  28. package/src/daemon/find-execution-refusal.ts +104 -0
  29. package/src/daemon/idempotency-ledger.ts +164 -0
  30. package/src/daemon/local-pty-provider.ts +83 -0
  31. package/src/daemon/mint-session-id.ts +5 -5
  32. package/src/daemon/parse-spawn-overrides.ts +94 -0
  33. package/src/daemon/permission-registry.ts +14 -4
  34. package/src/daemon/restore-fleet.ts +66 -19
  35. package/src/daemon/session-runtime.ts +10 -0
  36. package/src/daemon/sessions.ts +282 -65
  37. package/src/daemon/start-headless-run.ts +11 -0
  38. package/src/daemon/start-headless-turn.ts +11 -2
  39. package/src/daemon/target-access.ts +36 -0
  40. package/src/mcp/answer-mcp-request.ts +12 -4
  41. package/src/mcp/answer-rpc-request.ts +28 -1
  42. package/src/mcp/build-principal-caller.ts +15 -0
  43. package/src/mcp/build-spawn-descriptions.ts +45 -0
  44. package/src/mcp/build-tool-list.ts +32 -4
  45. package/src/mcp/mcp-tools.ts +98 -8
  46. package/src/mcp/parse-idempotency-key.ts +29 -0
  47. package/src/mcp/reconnecting-caller.ts +39 -7
  48. package/src/mcp/require-daemon-features.ts +7 -0
  49. package/src/mcp/run-tool.ts +63 -11
  50. package/src/mcp/types.ts +2 -0
  51. package/src/protocol/daemon-error.ts +5 -1
  52. package/src/protocol/daemon-features.ts +24 -0
  53. package/src/protocol/protocol.ts +44 -11
  54. package/src/protocol/request-param-schemas.ts +60 -10
  55. package/src/shared/agent-session-id.ts +1 -1
  56. package/src/shared/collect-principals.ts +51 -0
  57. package/src/shared/collect-targets.ts +144 -0
  58. package/src/shared/config.ts +139 -14
  59. package/src/shared/daemon-id.ts +8 -0
  60. package/src/shared/format-json-kind.ts +21 -0
  61. package/src/shared/sort-json-keys.ts +19 -0
  62. package/src/shared/to-daemon-id.ts +11 -0
  63. package/src/store/fleet-entry.ts +42 -9
  64. package/src/store/idempotency-record.ts +48 -0
  65. package/src/store/message-owner.ts +1 -1
  66. package/src/store/run-migrations.ts +254 -5
  67. package/src/store/state-store.ts +433 -31
  68. package/src/workspace/check-workspace-completeness.ts +90 -0
  69. package/src/workspace/create-workspace-clone.ts +239 -0
  70. package/src/workspace/normalize-git-url.ts +59 -0
  71. package/src/workspace/read-workspace-tar.ts +38 -0
  72. package/src/workspace/resolve-path-source.ts +170 -0
  73. package/src/workspace/run-git.ts +103 -0
  74. package/src/workspace/sanitize-workspace-clone.ts +146 -0
  75. package/src/workspace/workspace-provenance.ts +11 -0
  76. package/src/workspace/workspace-source.ts +8 -0
@@ -0,0 +1,36 @@
1
+ /**
2
+ * A target a session runs on, as the name it holds and the identity the
3
+ * session is bound to there.
4
+ */
5
+ export interface TargetGrant {
6
+ readonly target: string;
7
+ readonly targetIdentity: string;
8
+ }
9
+
10
+ /**
11
+ * The targets a principal may use, each as a name and an identity: a
12
+ * session on a granted name whose identity differs is outside the grant.
13
+ */
14
+ export class TargetAccess {
15
+ private readonly grants: ReadonlyMap<string, TargetGrant>;
16
+
17
+ constructor(grants: readonly TargetGrant[]) {
18
+ this.grants = new Map(grants.map((grant) => [buildGrantKey(grant), grant]));
19
+ }
20
+
21
+ canUse(grant: TargetGrant): boolean {
22
+ return this.grants.has(buildGrantKey(grant));
23
+ }
24
+
25
+ /**
26
+ * The grants both accesses hold, so a principal on a narrowed connection
27
+ * never reaches past the connection.
28
+ */
29
+ merge(other: TargetAccess): TargetAccess {
30
+ return new TargetAccess([...this.grants.values()].filter((grant) => other.canUse(grant)));
31
+ }
32
+ }
33
+
34
+ function buildGrantKey(grant: TargetGrant): string {
35
+ return JSON.stringify([grant.target, grant.targetIdentity]);
36
+ }
@@ -4,6 +4,7 @@ import type { GrantScope } from '../shared/grant-scope';
4
4
  import { normalizeClientName } from '../shared/normalize-client-name';
5
5
  import { isRecord } from '../shared/report';
6
6
  import { answerRPCRequest } from './answer-rpc-request';
7
+ import { buildPrincipalCaller } from './build-principal-caller';
7
8
  import { deriveTokenHash } from './derive-token-hash';
8
9
  import { findClientName } from './find-client-name';
9
10
  import { isSupportedProtocolVersion } from './is-supported-protocol-version';
@@ -21,7 +22,8 @@ interface MCPHTTPRequest {
21
22
  * database, so a revoked grant loses access at once, and a token bound to any
22
23
  * other resource is refused. A tool call outside the token's scopes is a 403
23
24
  * that names the missing scope. A message the client sends is always from the
24
- * client's own name.
25
+ * client's own name, and every request acts as the client's id, so the
26
+ * daemon limits it to the targets that principal may use.
25
27
  */
26
28
  export async function answerMCPRequest(
27
29
  ctx: HTTPServerContext,
@@ -65,7 +67,7 @@ export async function answerMCPRequest(
65
67
  }
66
68
 
67
69
  const outcome = await answerRPCRequest(message, {
68
- caller: ctx.caller,
70
+ caller: buildPrincipalCaller(ctx.caller, access.clientID),
69
71
  build: ctx.build,
70
72
  toolContext: { callerSessionID: null, sender: { kind: 'fixed', name: access.clientName } },
71
73
  scopes: access.scopes,
@@ -95,12 +97,13 @@ export async function answerMCPRequest(
95
97
  }
96
98
 
97
99
  interface VerifiedAccess {
100
+ readonly clientID: string;
98
101
  readonly clientName: string;
99
102
  readonly scopes: readonly GrantScope[];
100
103
  }
101
104
 
102
- // An inactive, unknown, or revoked token, or one bound to another resource,
103
- // verifies to null. Verifying stamps when the token's grant was last used.
105
+ // An inactive, unknown, or revoked token, one bound to another resource, or
106
+ // one without a client id verifies to null. Verifying stamps when the token's grant was last used.
104
107
  async function verifyAccessToken(
105
108
  ctx: HTTPServerContext,
106
109
  token: string,
@@ -124,11 +127,16 @@ async function verifyAccessToken(
124
127
  const granted = typeof payload['scope'] === 'string' ? payload['scope'].split(' ') : [];
125
128
  const clientID = typeof payload['client_id'] === 'string' ? payload['client_id'] : '';
126
129
 
130
+ if (clientID === '') {
131
+ return null;
132
+ }
133
+
127
134
  await upsertGrantUse(ctx, token);
128
135
 
129
136
  const storedName = await findClientName(ctx.store.db, clientID);
130
137
 
131
138
  return {
139
+ clientID,
132
140
  clientName: normalizeClientName(storedName, 'remote'),
133
141
  scopes: GRANT_SCOPES.filter((scope) => granted.includes(scope)),
134
142
  };
@@ -1,7 +1,9 @@
1
1
  import { match } from 'ts-pattern';
2
+ import { z } from 'zod';
2
3
  import { DaemonError } from '../protocol/daemon-error';
3
4
  import type { GrantScope } from '../shared/grant-scope';
4
5
  import { isRecord } from '../shared/report';
6
+ import type { RegisteredAgent } from './build-spawn-descriptions';
5
7
  import { buildToolList } from './build-tool-list';
6
8
  import { MCP_TOOLS } from './mcp-tools';
7
9
  import { pickProtocolVersion } from './pick-protocol-version';
@@ -60,9 +62,11 @@ export async function answerRPCRequest(message: unknown, deps: RPCDeps): Promise
60
62
  .with('tools/list', async () => {
61
63
  const features = await deps.caller.readFeatures();
62
64
 
65
+ const agents = features.has('agents.list') ? await tryReadAgents(deps) : null;
66
+
63
67
  return {
64
68
  kind: 'reply' as const,
65
- body: buildRPCResult(id, { tools: buildToolList(features) }),
69
+ body: buildRPCResult(id, { tools: buildToolList(features, agents) }),
66
70
  };
67
71
  })
68
72
  .with('tools/call', async () => {
@@ -97,6 +101,29 @@ function findMissingScope(
97
101
  return scopes.includes(needed) ? null : needed;
98
102
  }
99
103
 
104
+ // The part of an agents.list answer the spawn descriptions read.
105
+ const ROSTER_AGENT_SCHEMA = z.object({ id: z.string(), installed: z.boolean() });
106
+ const AGENT_ROSTER_SCHEMA = z.object({ agents: z.array(ROSTER_AGENT_SCHEMA) });
107
+
108
+ // The registered agents, or null when the daemon cannot answer, so the tool
109
+ // list still builds with descriptions that name no agent. A caller without
110
+ // the read scope gets null too, since listing agents is a read.
111
+ async function tryReadAgents(deps: RPCDeps): Promise<RegisteredAgent[] | null> {
112
+ if (deps.scopes !== undefined && !deps.scopes.includes('read')) {
113
+ return null;
114
+ }
115
+
116
+ try {
117
+ const answer = await deps.caller.sendRequest('agents.list');
118
+
119
+ const parsed = AGENT_ROSTER_SCHEMA.safeParse(answer);
120
+
121
+ return parsed.success ? parsed.data.agents : null;
122
+ } catch {
123
+ return null;
124
+ }
125
+ }
126
+
100
127
  async function answerToolCall(
101
128
  deps: RPCDeps,
102
129
  params: Readonly<Record<string, unknown>>,
@@ -0,0 +1,15 @@
1
+ import type { FleetCaller } from './types';
2
+
3
+ /**
4
+ * The caller every request of one remote MCP client rides: each request acts
5
+ * as the client's principal, whatever principal it asked for, and goes only
6
+ * to a daemon that limits a request to its principal's targets, so a daemon
7
+ * that would ignore the principal never answers it with the owner's reach.
8
+ */
9
+ export function buildPrincipalCaller(caller: FleetCaller, principal: string): FleetCaller {
10
+ return {
11
+ sendRequest: (m, p, required = []) =>
12
+ caller.sendRequest(m, p, [...required, 'request.principal'], principal),
13
+ readFeatures: () => caller.readFeatures(),
14
+ };
15
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * One agent as the spawn descriptions list it: its id and whether its binary
3
+ * resolves on the daemon's host.
4
+ */
5
+ export interface RegisteredAgent {
6
+ readonly id: string;
7
+ readonly installed: boolean;
8
+ }
9
+
10
+ interface SpawnDescriptions {
11
+ readonly tool: string;
12
+ readonly agent: string;
13
+ }
14
+
15
+ /**
16
+ * The `atc_session_spawn` description and its `agent` field description,
17
+ * naming the agents the daemon registered when the tool list was built. Only
18
+ * a registered id appears, and a registered agent whose binary is missing is
19
+ * marked not installed. null leaves every agent unnamed, for a tool list
20
+ * built while the daemon could not answer. Both point at `atc_agents_list`
21
+ * for the current list.
22
+ */
23
+ export function buildSpawnDescriptions(
24
+ agents: readonly RegisteredAgent[] | null,
25
+ ): SpawnDescriptions {
26
+ const roster =
27
+ agents === null
28
+ ? ''
29
+ : ` When this tool list was built, the host registered: ${formatRoster(agents)}.`;
30
+
31
+ return {
32
+ tool: `Spawn a new session in a directory. Optional agent is a registered agent id; omitted agent is always Claude, never the TUI last-used value.${roster} atc_agents_list returns the current agents, whether each is installed, and the model and effort each takes. An unregistered agent, a registered agent that is not installed, and a model or effort the agent does not take are refused before anything spawns. 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.`,
33
+ agent: `Registered agent id to spawn; defaults to claude.${roster} atc_agents_list returns the current list.`,
34
+ };
35
+ }
36
+
37
+ function formatRoster(agents: readonly RegisteredAgent[]): string {
38
+ if (agents.length === 0) {
39
+ return 'no agents';
40
+ }
41
+
42
+ return agents
43
+ .map((agent) => (agent.installed ? agent.id : `${agent.id} (not installed)`))
44
+ .join(', ');
45
+ }
@@ -1,5 +1,7 @@
1
1
  import type { DaemonFeature } from '../protocol/daemon-features';
2
2
  import { isRecord } from '../shared/report';
3
+ import { buildSpawnDescriptions } from './build-spawn-descriptions';
4
+ import type { RegisteredAgent } from './build-spawn-descriptions';
3
5
  import { MCP_TOOLS } from './mcp-tools';
4
6
 
5
7
  interface MCPTool {
@@ -19,8 +21,16 @@ interface MCPTool {
19
21
  * features. A tool the daemon cannot serve is left out, and a tool it serves
20
22
  * in an older form is listed without the output schema and input properties
21
23
  * that form lacks, so a client never sees an option the daemon would ignore.
24
+ * The spawn tool's description and its agent field's description name the
25
+ * registered agents; null leaves them unnamed. No schema depends on which
26
+ * agents the host registers or installs.
22
27
  */
23
- export function buildToolList(features: ReadonlySet<DaemonFeature>): readonly MCPTool[] {
28
+ export function buildToolList(
29
+ features: ReadonlySet<DaemonFeature>,
30
+ agents: readonly RegisteredAgent[] | null,
31
+ ): readonly MCPTool[] {
32
+ const spawn = buildSpawnDescriptions(agents);
33
+
24
34
  return MCP_TOOLS.flatMap((tool) => {
25
35
  const requires = tool.requires ?? {};
26
36
 
@@ -28,20 +38,23 @@ export function buildToolList(features: ReadonlySet<DaemonFeature>): readonly MC
28
38
  return [];
29
39
  }
30
40
 
31
- const inputSchema = buildInputSchema(tool.inputSchema, requires.inputs ?? {}, features);
41
+ const gated = buildInputSchema(tool.inputSchema, requires.inputs ?? {}, features);
42
+ const isSpawn = tool.name === 'atc_session_spawn';
43
+ const description = isSpawn ? spawn.tool : tool.description;
44
+ const inputSchema = isSpawn ? buildAgentFieldSchema(gated, spawn.agent) : gated;
32
45
 
33
46
  return [
34
47
  tool.outputSchema === undefined ||
35
48
  (requires.output !== undefined && !features.has(requires.output))
36
49
  ? {
37
50
  name: tool.name,
38
- description: tool.description,
51
+ description,
39
52
  inputSchema,
40
53
  annotations: tool.annotations,
41
54
  }
42
55
  : {
43
56
  name: tool.name,
44
- description: tool.description,
57
+ description,
45
58
  inputSchema,
46
59
  outputSchema: tool.outputSchema,
47
60
  annotations: tool.annotations,
@@ -72,3 +85,18 @@ function buildInputSchema(
72
85
  ),
73
86
  };
74
87
  }
88
+
89
+ // The input schema with only the agent field's description replaced.
90
+ function buildAgentFieldSchema(
91
+ schema: Readonly<Record<string, unknown>>,
92
+ description: string,
93
+ ): Readonly<Record<string, unknown>> {
94
+ const properties = schema['properties'];
95
+ const agent = isRecord(properties) ? properties['agent'] : undefined;
96
+
97
+ if (!isRecord(properties) || !isRecord(agent)) {
98
+ return schema;
99
+ }
100
+
101
+ return { ...schema, properties: { ...properties, agent: { ...agent, description } } };
102
+ }
@@ -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,28 @@ 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.',
28
44
  ),
29
45
  detached: z
30
46
  .boolean()
@@ -32,6 +48,7 @@ const SPAWN_INPUT: Readonly<Record<string, unknown>> = z.toJSONSchema(
32
48
  .describe(
33
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.',
34
50
  ),
51
+ idempotencyKey: IDEMPOTENCY_KEY_FIELD,
35
52
  }),
36
53
  { io: 'input' },
37
54
  );
@@ -124,6 +141,27 @@ const MESSAGE_SENT_OUTPUT: Readonly<Record<string, unknown>> = {
124
141
  required: ['message', 'status'],
125
142
  };
126
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
+
127
165
  const AGENTS_OUTPUT: Readonly<Record<string, unknown>> = {
128
166
  type: 'object',
129
167
  properties: {
@@ -159,8 +197,51 @@ const AGENTS_OUTPUT: Readonly<Record<string, unknown>> = {
159
197
  required: ['spawn', 'readTranscript', 'message', 'attach', 'screen', 'input'],
160
198
  },
161
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
+ },
162
205
  },
163
- required: ['id', 'label', 'kind', 'installed', 'capabilities', 'models'],
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'],
164
245
  },
165
246
  },
166
247
  },
@@ -264,9 +345,16 @@ export const MCP_TOOLS: readonly MCPToolDefinition[] = [
264
345
  name: 'atc_session_spawn',
265
346
  annotations: AGENT_FACING,
266
347
  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.',
348
+ description: buildSpawnDescriptions(null).tool,
269
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
+ },
270
358
  },
271
359
  {
272
360
  name: 'atc_session_input',
@@ -343,10 +431,10 @@ export const MCP_TOOLS: readonly MCPToolDefinition[] = [
343
431
  annotations: READ_ONLY,
344
432
  scope: 'read',
345
433
  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.",
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.",
347
435
  inputSchema: NO_INPUT,
348
436
  outputSchema: AGENTS_OUTPUT,
349
- requires: { tool: 'agents.list' },
437
+ requires: { tool: 'agents.list', output: 'spawn.options' },
350
438
  },
351
439
  {
352
440
  name: 'atc_session_get',
@@ -379,7 +467,7 @@ export const MCP_TOOLS: readonly MCPToolDefinition[] = [
379
467
  annotations: AGENT_FACING,
380
468
  scope: 'message',
381
469
  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.",
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.",
383
471
  inputSchema: {
384
472
  type: 'object',
385
473
  properties: {
@@ -390,11 +478,13 @@ export const MCP_TOOLS: readonly MCPToolDefinition[] = [
390
478
  description:
391
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',
392
480
  },
481
+ idempotencyKey: IDEMPOTENCY_KEY_INPUT,
393
482
  },
394
483
  required: ['session', 'text'],
395
484
  additionalProperties: false,
396
485
  },
397
486
  outputSchema: MESSAGE_SENT_OUTPUT,
487
+ requires: { inputs: { idempotencyKey: 'message.idempotency' } },
398
488
  },
399
489
  {
400
490
  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,17 @@ 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.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',
11
18
  };
12
19
 
13
20
  /**