@zgeoff/atc 2.11.0 → 2.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/README.md +10 -10
  2. package/package.json +2 -1
  3. package/src/agents/agent-adapter.ts +98 -10
  4. package/src/agents/build-args-without-flags.ts +25 -0
  5. package/src/agents/build-atc-bridge-files.ts +13 -0
  6. package/src/agents/build-claude-override-args.ts +26 -0
  7. package/src/agents/build-claude-query-options.ts +72 -0
  8. package/src/agents/build-cli-command.ts +7 -5
  9. package/src/{daemon → agents}/build-headless-env.ts +12 -6
  10. package/src/agents/build-hook-settings.ts +8 -2
  11. package/src/agents/build-restore-mode-args.ts +23 -0
  12. package/src/agents/claude-adapter.ts +112 -18
  13. package/src/agents/claude-effort-levels.ts +5 -0
  14. package/src/agents/codex-adapter.ts +48 -2
  15. package/src/agents/find-claude-permission-mode.ts +24 -0
  16. package/src/agents/find-flag-value.ts +27 -0
  17. package/src/agents/gateway-adapter.ts +83 -13
  18. package/src/agents/grok-adapter.ts +24 -1
  19. package/src/agents/make-claude-headless-runner.ts +46 -0
  20. package/src/agents/plan-pasted-line-input.ts +22 -0
  21. package/src/agents/plan-typed-line-input.ts +8 -0
  22. package/src/agents/resolve-claude-permission-mode.ts +13 -0
  23. package/src/{daemon/start-headless-run.ts → agents/start-claude-headless-run.ts} +6 -42
  24. package/src/agents/write-atc-bridge.ts +2 -6
  25. package/src/cli.ts +23 -9
  26. package/src/client/daemon-client.ts +14 -2
  27. package/src/client/index.ts +50 -1
  28. package/src/client/spawn-picker.ts +27 -4
  29. package/src/client/ui.ts +8 -0
  30. package/src/daemon/build-agent-list.ts +37 -1
  31. package/src/daemon/build-config-revision.ts +27 -0
  32. package/src/daemon/build-execution-targets.ts +73 -0
  33. package/src/daemon/build-fleet-events.ts +2 -1
  34. package/src/daemon/build-imp-provider.ts +68 -0
  35. package/src/daemon/build-payload-hash.ts +31 -0
  36. package/src/daemon/build-report-trail-entry.ts +4 -2
  37. package/src/daemon/build-scoped-context.ts +235 -0
  38. package/src/daemon/build-session-lifecycle.ts +52 -0
  39. package/src/daemon/build-tar-archive.ts +85 -0
  40. package/src/daemon/build-target-access.ts +33 -0
  41. package/src/daemon/build-target-forbidden-error.ts +13 -0
  42. package/src/daemon/build-target-identity.ts +22 -0
  43. package/src/daemon/build-target-list.ts +50 -0
  44. package/src/daemon/daemon-connection.ts +425 -96
  45. package/src/daemon/daemon.ts +730 -96
  46. package/src/daemon/effect-remains-error.ts +13 -0
  47. package/src/daemon/execution-provider.ts +208 -0
  48. package/src/daemon/find-execution-refusal.ts +104 -0
  49. package/src/daemon/hooks.ts +5 -17
  50. package/src/daemon/idempotency-ledger.ts +164 -0
  51. package/src/daemon/imp-client-port.ts +343 -0
  52. package/src/daemon/imp-harness.ts +618 -0
  53. package/src/daemon/imp-port-error.ts +18 -0
  54. package/src/daemon/imp-port.ts +246 -0
  55. package/src/daemon/imp-provider.ts +601 -0
  56. package/src/daemon/is-binding-current.ts +43 -0
  57. package/src/daemon/local-pty-provider.ts +142 -0
  58. package/src/daemon/materialize-workspace.ts +571 -0
  59. package/src/daemon/mint-session-id.ts +5 -5
  60. package/src/daemon/parse-hook-line.ts +31 -0
  61. package/src/daemon/parse-spawn-overrides.ts +94 -0
  62. package/src/daemon/permission-registry.ts +14 -4
  63. package/src/daemon/pick-session-state.ts +15 -0
  64. package/src/daemon/restore-fleet.ts +103 -38
  65. package/src/daemon/screen-model.ts +7 -0
  66. package/src/daemon/session-runtime.ts +10 -0
  67. package/src/daemon/sessions.ts +835 -105
  68. package/src/daemon/start-headless-turn.ts +12 -3
  69. package/src/daemon/start-session-bridge.ts +294 -0
  70. package/src/daemon/target-access.ts +36 -0
  71. package/src/mcp/answer-mcp-request.ts +12 -4
  72. package/src/mcp/answer-rpc-request.ts +28 -1
  73. package/src/mcp/build-principal-caller.ts +15 -0
  74. package/src/mcp/build-spawn-descriptions.ts +45 -0
  75. package/src/mcp/build-tool-list.ts +32 -4
  76. package/src/mcp/mcp-tools.ts +107 -10
  77. package/src/mcp/parse-idempotency-key.ts +29 -0
  78. package/src/mcp/reconnecting-caller.ts +39 -7
  79. package/src/mcp/require-daemon-features.ts +10 -0
  80. package/src/mcp/run-tool.ts +87 -16
  81. package/src/mcp/types.ts +2 -0
  82. package/src/protocol/daemon-error.ts +5 -1
  83. package/src/protocol/daemon-features.ts +35 -0
  84. package/src/protocol/protocol.ts +67 -11
  85. package/src/protocol/request-param-schemas.ts +126 -10
  86. package/src/report.ts +37 -2
  87. package/src/run-bridge-tap.ts +241 -0
  88. package/src/shared/agent-session-id.ts +1 -1
  89. package/src/shared/collect-clean-env.ts +9 -1
  90. package/src/shared/collect-principals.ts +51 -0
  91. package/src/shared/collect-targets.ts +144 -0
  92. package/src/shared/config.ts +139 -14
  93. package/src/shared/daemon-id.ts +8 -0
  94. package/src/shared/format-json-kind.ts +21 -0
  95. package/src/shared/open-bridge-socket.ts +86 -0
  96. package/src/shared/send-bridge-request.ts +47 -0
  97. package/src/shared/sort-json-keys.ts +19 -0
  98. package/src/shared/to-daemon-id.ts +11 -0
  99. package/src/statusline.ts +34 -3
  100. package/src/store/fleet-entry.ts +61 -9
  101. package/src/store/idempotency-record.ts +48 -0
  102. package/src/store/message-owner.ts +1 -1
  103. package/src/store/run-migrations.ts +330 -5
  104. package/src/store/state-store.ts +607 -44
  105. package/src/store/trail-entry.ts +4 -0
  106. package/src/store/workspace-materialization.ts +70 -0
  107. package/src/tap.ts +11 -0
  108. package/src/workspace/check-url-credentials.ts +59 -0
  109. package/src/workspace/check-workspace-completeness.ts +90 -0
  110. package/src/workspace/create-workspace-clone.ts +251 -0
  111. package/src/workspace/normalize-git-url.ts +59 -0
  112. package/src/workspace/read-workspace-tar.ts +38 -0
  113. package/src/workspace/repository-env-vars.ts +22 -0
  114. package/src/workspace/resolve-path-source.ts +170 -0
  115. package/src/workspace/run-git.ts +87 -0
  116. package/src/workspace/sanitize-workspace-clone.ts +146 -0
  117. package/src/workspace/workspace-provenance.ts +11 -0
  118. package/src/workspace/workspace-source.ts +8 -0
  119. /package/src/{daemon → agents}/resolve-headless-executable.ts +0 -0
@@ -10,8 +10,9 @@ import { truncateSummary } from './truncate-summary';
10
10
  * runner, wires the run's output and completion into the session manager,
11
11
  * passes the turn's start and end to the event trail the way a terminal
12
12
  * turn's hooks do, and records the live handle on the session's runtime. Returns false
13
- * without starting anything when the session is unknown, its agent has no
14
- * headless runner, or a turn is already running.
13
+ * without starting anything when the session is unknown or exited, its
14
+ * agent has no headless runner, its target does not serve headless turns,
15
+ * or a turn is already running.
15
16
  */
16
17
  export function startHeadlessTurn(
17
18
  mgr: SessionManager,
@@ -28,6 +29,12 @@ export function startHeadlessTurn(
28
29
  return false;
29
30
  }
30
31
 
32
+ // The runner runs on the daemon's own host, so a session whose target
33
+ // does not serve headless turns never reaches it.
34
+ if (s.state === 'exited' || mgr.findExecutionRefusal(s, 'headless') !== null) {
35
+ return false;
36
+ }
37
+
31
38
  mgr.updateSurfaceState(sessionID, 'running', 'headless turn running');
32
39
 
33
40
  recordTurnEvent(sessionID, { kind: 'prompt-submitted', detail: truncateDetail(prompt) });
@@ -37,8 +44,10 @@ export function startHeadlessTurn(
37
44
  cwd: s.cwd,
38
45
  prompt,
39
46
  ...(s.agentSessionID === undefined ? {} : { resume: s.agentSessionID }),
40
- permissionMode: 'auto',
41
47
  sessionID,
48
+ ...(s.model === undefined ? {} : { model: s.model }),
49
+ ...(s.effort === undefined ? {} : { effort: s.effort }),
50
+ ...(s.withheldEnv.length === 0 ? {} : { withheldEnv: s.withheldEnv }),
42
51
  },
43
52
  {
44
53
  onOutput: (text) => {
@@ -0,0 +1,294 @@
1
+ import { z } from 'zod';
2
+ import type { EventMsg } from '../protocol/protocol';
3
+ import type { MessageID } from '../shared/message-id';
4
+ import type { SessionID } from '../shared/session-id';
5
+ import { toMessageID } from '../shared/to-message-id';
6
+ import type { MessageRecord } from '../store/message-record';
7
+ import type { TapClient } from './daemon-connection';
8
+ import type { HarnessRelay } from './execution-provider';
9
+ import type { HookEvent } from './hooks';
10
+ import { isBindingCurrent } from './is-binding-current';
11
+ import type { BridgeBinding } from './is-binding-current';
12
+ import { parseHookLine } from './parse-hook-line';
13
+
14
+ /**
15
+ * The fields of a live session the bridge checks its binding against and
16
+ * reads the status from.
17
+ */
18
+ export interface BridgeSession {
19
+ readonly id: SessionID;
20
+ readonly target: string;
21
+ readonly targetIdentity: string;
22
+ readonly hostKey: SessionID;
23
+ readonly bridgeEpoch: number;
24
+ readonly state: string;
25
+ readonly lastMsg: string;
26
+ }
27
+
28
+ /**
29
+ * What the bridge needs from the daemon. Each operation acts on the one
30
+ * session the caller names, which is always the bridge's own.
31
+ */
32
+ export interface BridgeContext {
33
+ readonly findSession: (sessionID: SessionID) => BridgeSession | undefined;
34
+ readonly applyHookEvent: (e: HookEvent) => void;
35
+
36
+ // Applies a report under the id its reporter gave it, and settles once
37
+ // the report is recorded; false when the payload is no report.
38
+ readonly applyReport: (
39
+ sessionID: SessionID,
40
+ payload: Readonly<Record<string, unknown>>,
41
+ reportID: string,
42
+ ) => Promise<boolean>;
43
+ readonly attachTap: (client: TapClient, sessionID: SessionID) => 'ok' | 'missing' | 'unsupported';
44
+ readonly ackMessage: (
45
+ client: TapClient,
46
+ sessionID: SessionID,
47
+ messageID: MessageID,
48
+ ) => Promise<MessageRecord | 'not_tapping' | 'unknown'>;
49
+ readonly detachTap: (client: TapClient) => void;
50
+ }
51
+
52
+ const REQUEST_SCHEMA = z.looseObject({ v: z.literal(1), id: z.string().min(1), op: z.string() });
53
+
54
+ const REPORT_SCHEMA = z.looseObject({
55
+ reportID: z.string().min(1).max(128),
56
+ payload: z.record(z.string(), z.unknown()),
57
+ });
58
+
59
+ const ACK_SCHEMA = z.looseObject({ message: z.string().min(1) });
60
+
61
+ type BridgeRefusal = 'forbidden' | 'stale_binding' | 'not_tapping' | 'unknown_message';
62
+
63
+ /**
64
+ * Serves one connection a remote harness's processes opened to the daemon:
65
+ * the session bridge. A line without an `op` is a hook line, as the local
66
+ * reporter socket takes it. A request line `{ v: 1, id, op }` gets one
67
+ * answer line `{ id, ok }`, and the bridge's own inbox events go out as
68
+ * protocol event lines.
69
+ *
70
+ * The bridge takes hooks, reports, its session's tap and acks, and its
71
+ * session's own status, for the one session it was bound to and nothing
72
+ * else: an unknown op, a malformed request, or a report or hook for any
73
+ * other session answers `forbidden` and closes the connection. Every line
74
+ * checks the binding against the live session first, and one that no
75
+ * longer matches answers `stale_binding` and closes.
76
+ *
77
+ * Trust: the binding holds no secret. A host serves the bridge's socket
78
+ * inside itself alone, so reaching the socket is the proof of being inside
79
+ * that host. One host is one trust domain: a sub-session in its parent's
80
+ * host runs as the same user and can reach the parent's socket too. The
81
+ * per-session socket and binding stop processes in the host from crossing
82
+ * sessions by accident, never a hostile process inside the same host.
83
+ */
84
+ export function startSessionBridge(
85
+ relay: HarnessRelay,
86
+ binding: Readonly<BridgeBinding>,
87
+ ctx: BridgeContext,
88
+ ): void {
89
+ let closed = false;
90
+ let writing: Promise<void> = Promise.resolve();
91
+
92
+ // Lines go out one at a time, in the order they were written, and none
93
+ // after the bridge closed.
94
+ const writeLine = async (value: Readonly<Record<string, unknown>>) => {
95
+ const line = JSON.stringify(value);
96
+ const previous = writing;
97
+
98
+ writing = (async () => {
99
+ await previous;
100
+
101
+ if (!closed) {
102
+ try {
103
+ await relay.writeLine(line);
104
+ } catch {}
105
+ }
106
+ })();
107
+
108
+ await writing;
109
+ };
110
+
111
+ const stopBridge = () => {
112
+ if (closed) {
113
+ return;
114
+ }
115
+
116
+ closed = true;
117
+
118
+ ctx.detachTap(tap);
119
+ relay.close();
120
+ };
121
+
122
+ // Answers a refusal, then closes once the answer has gone out.
123
+ const answerRefusal = async (id: string | null, code: BridgeRefusal) => {
124
+ await writeLine({ id, ok: false, code });
125
+
126
+ stopBridge();
127
+ };
128
+
129
+ const tap: TapClient = {
130
+ sendEvent: (event: EventMsg) => {
131
+ void (async () => {
132
+ await writeLine(event);
133
+
134
+ if (event.ev === 'InboxClosed') {
135
+ stopBridge();
136
+ }
137
+ })();
138
+ },
139
+ };
140
+
141
+ const isCurrent = () => isBindingCurrent(binding, ctx.findSession(binding.sessionID));
142
+
143
+ const applyRequest = async (
144
+ request: Readonly<Record<string, unknown>>,
145
+ id: string,
146
+ op: string,
147
+ ) => {
148
+ if (op === 'report') {
149
+ const parsed = REPORT_SCHEMA.safeParse(request);
150
+
151
+ if (
152
+ !parsed.success ||
153
+ !(await ctx.applyReport(binding.sessionID, parsed.data.payload, parsed.data.reportID))
154
+ ) {
155
+ void answerRefusal(id, 'forbidden');
156
+
157
+ return;
158
+ }
159
+
160
+ void writeLine({ id, ok: true });
161
+
162
+ return;
163
+ }
164
+
165
+ if (op === 'tap.open') {
166
+ if (ctx.attachTap(tap, binding.sessionID) !== 'ok') {
167
+ void answerRefusal(id, 'forbidden');
168
+
169
+ return;
170
+ }
171
+
172
+ void writeLine({ id, ok: true });
173
+
174
+ return;
175
+ }
176
+
177
+ if (op === 'tap.ack') {
178
+ const parsed = ACK_SCHEMA.safeParse(request);
179
+
180
+ if (!parsed.success) {
181
+ void answerRefusal(id, 'forbidden');
182
+
183
+ return;
184
+ }
185
+
186
+ const acked = await ctx.ackMessage(tap, binding.sessionID, toMessageID(parsed.data.message));
187
+
188
+ if (acked === 'not_tapping' || acked === 'unknown') {
189
+ const code: BridgeRefusal = acked === 'unknown' ? 'unknown_message' : 'not_tapping';
190
+
191
+ void writeLine({ id, ok: false, code });
192
+
193
+ return;
194
+ }
195
+
196
+ void writeLine({ id, ok: true, status: acked.status });
197
+
198
+ return;
199
+ }
200
+
201
+ if (op === 'status.read') {
202
+ const s = ctx.findSession(binding.sessionID);
203
+
204
+ void writeLine({ id, ok: true, state: s?.state ?? 'exited', lastMsg: s?.lastMsg ?? '' });
205
+
206
+ return;
207
+ }
208
+
209
+ void answerRefusal(id, 'forbidden');
210
+ };
211
+
212
+ // Lines are applied one at a time, in the order they arrived.
213
+ let applying: Promise<void> = Promise.resolve();
214
+
215
+ const applyLine = async (line: string) => {
216
+ if (closed) {
217
+ return;
218
+ }
219
+
220
+ let parsed: unknown;
221
+
222
+ try {
223
+ parsed = JSON.parse(line);
224
+ } catch {
225
+ void answerRefusal(null, 'forbidden');
226
+
227
+ return;
228
+ }
229
+
230
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
231
+ void answerRefusal(null, 'forbidden');
232
+
233
+ return;
234
+ }
235
+
236
+ const record: Readonly<Record<string, unknown>> = Object.fromEntries(Object.entries(parsed));
237
+
238
+ if (!('op' in record)) {
239
+ const e = parseHookLine(line);
240
+
241
+ if (!isCurrent()) {
242
+ void answerRefusal(null, 'stale_binding');
243
+
244
+ return;
245
+ }
246
+
247
+ if (e === null || e.atcId !== binding.sessionID) {
248
+ void answerRefusal(null, 'forbidden');
249
+
250
+ return;
251
+ }
252
+
253
+ ctx.applyHookEvent(e);
254
+
255
+ return;
256
+ }
257
+
258
+ const request = REQUEST_SCHEMA.safeParse(record);
259
+
260
+ if (!request.success) {
261
+ const id = typeof record['id'] === 'string' ? record['id'] : null;
262
+
263
+ void answerRefusal(id, 'forbidden');
264
+
265
+ return;
266
+ }
267
+
268
+ if (!isCurrent()) {
269
+ void answerRefusal(request.data.id, 'stale_binding');
270
+
271
+ return;
272
+ }
273
+
274
+ await applyRequest(record, request.data.id, request.data.op);
275
+ };
276
+
277
+ relay.onLine((line) => {
278
+ const previous = applying;
279
+
280
+ applying = (async () => {
281
+ await previous;
282
+
283
+ try {
284
+ await applyLine(line);
285
+ } catch {}
286
+ })();
287
+ });
288
+
289
+ relay.onClose(() => {
290
+ closed = true;
291
+
292
+ ctx.detachTap(tap);
293
+ });
294
+ }
@@ -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
+ }