@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.
- package/package.json +1 -1
- package/src/agents/agent-adapter.ts +51 -0
- package/src/agents/build-args-without-flags.ts +25 -0
- package/src/agents/build-claude-override-args.ts +26 -0
- package/src/agents/claude-adapter.ts +49 -2
- package/src/agents/claude-effort-levels.ts +5 -0
- package/src/agents/codex-adapter.ts +43 -2
- package/src/agents/find-flag-value.ts +27 -0
- package/src/agents/gateway-adapter.ts +45 -2
- package/src/agents/grok-adapter.ts +19 -1
- package/src/cli.ts +18 -0
- package/src/client/daemon-client.ts +14 -2
- package/src/client/spawn-picker.ts +27 -4
- 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 +34 -0
- package/src/daemon/build-fleet-events.ts +2 -1
- package/src/daemon/build-payload-hash.ts +31 -0
- package/src/daemon/build-scoped-context.ts +232 -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 +331 -95
- package/src/daemon/daemon.ts +452 -75
- package/src/daemon/effect-remains-error.ts +13 -0
- package/src/daemon/execution-provider.ts +103 -0
- package/src/daemon/find-execution-refusal.ts +104 -0
- package/src/daemon/idempotency-ledger.ts +164 -0
- package/src/daemon/local-pty-provider.ts +83 -0
- package/src/daemon/mint-session-id.ts +5 -5
- package/src/daemon/parse-spawn-overrides.ts +94 -0
- package/src/daemon/permission-registry.ts +14 -4
- package/src/daemon/restore-fleet.ts +66 -19
- package/src/daemon/session-runtime.ts +10 -0
- package/src/daemon/sessions.ts +282 -65
- package/src/daemon/start-headless-run.ts +11 -0
- package/src/daemon/start-headless-turn.ts +11 -2
- 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 +98 -8
- package/src/mcp/parse-idempotency-key.ts +29 -0
- package/src/mcp/reconnecting-caller.ts +39 -7
- package/src/mcp/require-daemon-features.ts +7 -0
- package/src/mcp/run-tool.ts +63 -11
- package/src/mcp/types.ts +2 -0
- package/src/protocol/daemon-error.ts +5 -1
- package/src/protocol/daemon-features.ts +24 -0
- package/src/protocol/protocol.ts +44 -11
- package/src/protocol/request-param-schemas.ts +60 -10
- package/src/shared/agent-session-id.ts +1 -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/sort-json-keys.ts +19 -0
- package/src/shared/to-daemon-id.ts +11 -0
- package/src/store/fleet-entry.ts +42 -9
- package/src/store/idempotency-record.ts +48 -0
- package/src/store/message-owner.ts +1 -1
- package/src/store/run-migrations.ts +254 -5
- package/src/store/state-store.ts +433 -31
- package/src/workspace/check-workspace-completeness.ts +90 -0
- package/src/workspace/create-workspace-clone.ts +239 -0
- package/src/workspace/normalize-git-url.ts +59 -0
- package/src/workspace/read-workspace-tar.ts +38 -0
- package/src/workspace/resolve-path-source.ts +170 -0
- package/src/workspace/run-git.ts +103 -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
|
@@ -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,
|
|
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(
|
|
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
|
|
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
|
|
51
|
+
description,
|
|
39
52
|
inputSchema,
|
|
40
53
|
annotations: tool.annotations,
|
|
41
54
|
}
|
|
42
55
|
: {
|
|
43
56
|
name: tool.name,
|
|
44
|
-
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
|
+
}
|
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,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
|
-
|
|
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),
|
|
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 (
|
|
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
|
|
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,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
|
/**
|