@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.
- package/README.md +10 -10
- package/package.json +2 -1
- package/src/agents/agent-adapter.ts +98 -10
- package/src/agents/build-args-without-flags.ts +25 -0
- package/src/agents/build-atc-bridge-files.ts +13 -0
- package/src/agents/build-claude-override-args.ts +26 -0
- package/src/agents/build-claude-query-options.ts +72 -0
- package/src/agents/build-cli-command.ts +7 -5
- package/src/{daemon → agents}/build-headless-env.ts +12 -6
- package/src/agents/build-hook-settings.ts +8 -2
- package/src/agents/build-restore-mode-args.ts +23 -0
- package/src/agents/claude-adapter.ts +112 -18
- package/src/agents/claude-effort-levels.ts +5 -0
- package/src/agents/codex-adapter.ts +48 -2
- package/src/agents/find-claude-permission-mode.ts +24 -0
- package/src/agents/find-flag-value.ts +27 -0
- package/src/agents/gateway-adapter.ts +83 -13
- package/src/agents/grok-adapter.ts +24 -1
- package/src/agents/make-claude-headless-runner.ts +46 -0
- package/src/agents/plan-pasted-line-input.ts +22 -0
- package/src/agents/plan-typed-line-input.ts +8 -0
- package/src/agents/resolve-claude-permission-mode.ts +13 -0
- package/src/{daemon/start-headless-run.ts → agents/start-claude-headless-run.ts} +6 -42
- package/src/agents/write-atc-bridge.ts +2 -6
- package/src/cli.ts +23 -9
- package/src/client/daemon-client.ts +14 -2
- package/src/client/index.ts +50 -1
- package/src/client/spawn-picker.ts +27 -4
- package/src/client/ui.ts +8 -0
- package/src/daemon/build-agent-list.ts +37 -1
- package/src/daemon/build-config-revision.ts +27 -0
- package/src/daemon/build-execution-targets.ts +73 -0
- package/src/daemon/build-fleet-events.ts +2 -1
- package/src/daemon/build-imp-provider.ts +68 -0
- package/src/daemon/build-payload-hash.ts +31 -0
- package/src/daemon/build-report-trail-entry.ts +4 -2
- package/src/daemon/build-scoped-context.ts +235 -0
- package/src/daemon/build-session-lifecycle.ts +52 -0
- package/src/daemon/build-tar-archive.ts +85 -0
- package/src/daemon/build-target-access.ts +33 -0
- package/src/daemon/build-target-forbidden-error.ts +13 -0
- package/src/daemon/build-target-identity.ts +22 -0
- package/src/daemon/build-target-list.ts +50 -0
- package/src/daemon/daemon-connection.ts +425 -96
- package/src/daemon/daemon.ts +730 -96
- package/src/daemon/effect-remains-error.ts +13 -0
- package/src/daemon/execution-provider.ts +208 -0
- package/src/daemon/find-execution-refusal.ts +104 -0
- package/src/daemon/hooks.ts +5 -17
- package/src/daemon/idempotency-ledger.ts +164 -0
- package/src/daemon/imp-client-port.ts +343 -0
- package/src/daemon/imp-harness.ts +618 -0
- package/src/daemon/imp-port-error.ts +18 -0
- package/src/daemon/imp-port.ts +246 -0
- package/src/daemon/imp-provider.ts +601 -0
- package/src/daemon/is-binding-current.ts +43 -0
- package/src/daemon/local-pty-provider.ts +142 -0
- package/src/daemon/materialize-workspace.ts +571 -0
- package/src/daemon/mint-session-id.ts +5 -5
- package/src/daemon/parse-hook-line.ts +31 -0
- package/src/daemon/parse-spawn-overrides.ts +94 -0
- package/src/daemon/permission-registry.ts +14 -4
- package/src/daemon/pick-session-state.ts +15 -0
- package/src/daemon/restore-fleet.ts +103 -38
- package/src/daemon/screen-model.ts +7 -0
- package/src/daemon/session-runtime.ts +10 -0
- package/src/daemon/sessions.ts +835 -105
- package/src/daemon/start-headless-turn.ts +12 -3
- package/src/daemon/start-session-bridge.ts +294 -0
- package/src/daemon/target-access.ts +36 -0
- package/src/mcp/answer-mcp-request.ts +12 -4
- package/src/mcp/answer-rpc-request.ts +28 -1
- package/src/mcp/build-principal-caller.ts +15 -0
- package/src/mcp/build-spawn-descriptions.ts +45 -0
- package/src/mcp/build-tool-list.ts +32 -4
- package/src/mcp/mcp-tools.ts +107 -10
- package/src/mcp/parse-idempotency-key.ts +29 -0
- package/src/mcp/reconnecting-caller.ts +39 -7
- package/src/mcp/require-daemon-features.ts +10 -0
- package/src/mcp/run-tool.ts +87 -16
- package/src/mcp/types.ts +2 -0
- package/src/protocol/daemon-error.ts +5 -1
- package/src/protocol/daemon-features.ts +35 -0
- package/src/protocol/protocol.ts +67 -11
- package/src/protocol/request-param-schemas.ts +126 -10
- package/src/report.ts +37 -2
- package/src/run-bridge-tap.ts +241 -0
- package/src/shared/agent-session-id.ts +1 -1
- package/src/shared/collect-clean-env.ts +9 -1
- package/src/shared/collect-principals.ts +51 -0
- package/src/shared/collect-targets.ts +144 -0
- package/src/shared/config.ts +139 -14
- package/src/shared/daemon-id.ts +8 -0
- package/src/shared/format-json-kind.ts +21 -0
- package/src/shared/open-bridge-socket.ts +86 -0
- package/src/shared/send-bridge-request.ts +47 -0
- package/src/shared/sort-json-keys.ts +19 -0
- package/src/shared/to-daemon-id.ts +11 -0
- package/src/statusline.ts +34 -3
- package/src/store/fleet-entry.ts +61 -9
- package/src/store/idempotency-record.ts +48 -0
- package/src/store/message-owner.ts +1 -1
- package/src/store/run-migrations.ts +330 -5
- package/src/store/state-store.ts +607 -44
- package/src/store/trail-entry.ts +4 -0
- package/src/store/workspace-materialization.ts +70 -0
- package/src/tap.ts +11 -0
- package/src/workspace/check-url-credentials.ts +59 -0
- package/src/workspace/check-workspace-completeness.ts +90 -0
- package/src/workspace/create-workspace-clone.ts +251 -0
- package/src/workspace/normalize-git-url.ts +59 -0
- package/src/workspace/read-workspace-tar.ts +38 -0
- package/src/workspace/repository-env-vars.ts +22 -0
- package/src/workspace/resolve-path-source.ts +170 -0
- package/src/workspace/run-git.ts +87 -0
- package/src/workspace/sanitize-workspace-clone.ts +146 -0
- package/src/workspace/workspace-provenance.ts +11 -0
- package/src/workspace/workspace-source.ts +8 -0
- /package/src/{daemon → agents}/resolve-headless-executable.ts +0 -0
package/src/mcp/mcp-tools.ts
CHANGED
|
@@ -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
|
-
|
|
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', '
|
|
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
|
-
|
|
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: {
|
|
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),
|
|
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 (
|
|
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
|
|
31
|
-
*
|
|
32
|
-
*
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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
|
/**
|
package/src/mcp/run-tool.ts
CHANGED
|
@@ -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(
|
|
83
|
+
return buildObjectResult(session);
|
|
52
84
|
})
|
|
53
85
|
.with('atc_session_input', async () => {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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 (
|
|
190
|
-
|
|
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
|
-
|
|
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];
|