@zgeoff/atc 2.20.0 → 2.22.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 (57) hide show
  1. package/package.json +1 -1
  2. package/src/cli.ts +187 -118
  3. package/src/client/daemon-client.ts +29 -18
  4. package/src/daemon/build-payload-hash.ts +4 -3
  5. package/src/daemon/daemon-connection.ts +270 -20
  6. package/src/daemon/daemon-context.ts +8 -0
  7. package/src/daemon/daemon.ts +186 -32
  8. package/src/daemon/find-token-fingerprint.ts +23 -0
  9. package/src/daemon/handshake-throttle.ts +47 -0
  10. package/src/daemon/idempotency-ledger.ts +20 -2
  11. package/src/daemon/is-allowed-listen-host.ts +63 -0
  12. package/src/daemon/load-listener-tokens.ts +46 -0
  13. package/src/daemon/parse-listen-address.ts +33 -0
  14. package/src/daemon/start-tcp-listener.ts +165 -0
  15. package/src/federation/build-binding-payload-hash.ts +34 -0
  16. package/src/federation/build-daemon-outdated-error.ts +14 -0
  17. package/src/federation/build-events-filter-hash.ts +16 -0
  18. package/src/federation/build-gateway-error.ts +45 -0
  19. package/src/federation/build-gateway-id.ts +14 -0
  20. package/src/federation/build-gateway-result.ts +31 -0
  21. package/src/federation/build-ruled-value.ts +53 -0
  22. package/src/federation/collect-unruled-id-paths.ts +46 -0
  23. package/src/federation/daemon-caller.ts +473 -0
  24. package/src/federation/daemon-pool.ts +56 -0
  25. package/src/federation/decode-gateway-cursor.ts +73 -0
  26. package/src/federation/encode-gateway-cursor.ts +15 -0
  27. package/src/federation/gateway-error.ts +25 -0
  28. package/src/federation/gateway-store.ts +253 -0
  29. package/src/federation/id-rules.ts +85 -0
  30. package/src/federation/load-gateway-registry.ts +29 -0
  31. package/src/federation/max-events-cursor-bytes.ts +4 -0
  32. package/src/federation/max-registry-daemons.ts +26 -0
  33. package/src/federation/merge-event-pages.ts +228 -0
  34. package/src/federation/open-gateway-caller.ts +55 -0
  35. package/src/federation/parse-gateway-id.ts +28 -0
  36. package/src/federation/parse-gateway-registry.ts +123 -0
  37. package/src/federation/pick-daemon-state.ts +46 -0
  38. package/src/federation/plan-event-reads.ts +54 -0
  39. package/src/federation/read-fleet-events.ts +279 -0
  40. package/src/federation/require-serving-daemon.ts +27 -0
  41. package/src/federation/resolve-daemon-request.ts +59 -0
  42. package/src/federation/routing-caller.ts +450 -0
  43. package/src/federation/types.ts +33 -0
  44. package/src/federation/wait-for-outcome.ts +38 -0
  45. package/src/mcp/answer-rpc-request.ts +14 -1
  46. package/src/mcp/build-tool-list.ts +6 -5
  47. package/src/mcp/mcp-tools.ts +41 -8
  48. package/src/mcp/require-daemon-features.ts +2 -0
  49. package/src/mcp/run-tool.ts +15 -1
  50. package/src/mcp/start-mcp-http-server.ts +53 -9
  51. package/src/mcp/types.ts +8 -1
  52. package/src/protocol/daemon-features.ts +9 -0
  53. package/src/protocol/protocol.ts +1 -0
  54. package/src/protocol/request-param-schemas.ts +6 -0
  55. package/src/run-daemon-id.ts +52 -0
  56. package/src/shared/find-daemon-record.ts +8 -3
  57. package/src/store/state-store.ts +16 -0
@@ -0,0 +1,450 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { match } from 'ts-pattern';
3
+ import { DaemonError } from '../protocol/daemon-error';
4
+ import type { DaemonFeature } from '../protocol/daemon-features';
5
+ import { buildBindingPayloadHash } from './build-binding-payload-hash';
6
+ import { buildGatewayError } from './build-gateway-error';
7
+ import { buildGatewayResult } from './build-gateway-result';
8
+ import type { DaemonCaller, ParamsPreparer } from './daemon-caller';
9
+ import type { DaemonPool } from './daemon-pool';
10
+ import { GatewayError } from './gateway-error';
11
+ import type { GatewayStore } from './gateway-store';
12
+ import { pickDaemonState } from './pick-daemon-state';
13
+ import { readFleetEvents } from './read-fleet-events';
14
+ import { requireServingDaemon } from './require-serving-daemon';
15
+ import { resolveDaemonRequest } from './resolve-daemon-request';
16
+ import type { GatewayFeature, GatewayRegistry, RegistryDaemon } from './types';
17
+ import { waitForOutcome } from './wait-for-outcome';
18
+ import type { CallOutcome } from './wait-for-outcome';
19
+
20
+ interface RoutingCallerOptions {
21
+ readonly registry: GatewayRegistry;
22
+ readonly pool: DaemonPool;
23
+ readonly store: GatewayStore;
24
+
25
+ // How long each daemon may take to answer a call the gateway asks every
26
+ // daemon; 5 s when unset.
27
+ readonly fanOutTimeoutMs?: number;
28
+ readonly now?: () => number;
29
+ }
30
+
31
+ const FAN_OUT_TIMEOUT_MS = 5000;
32
+
33
+ // The requests a daemon runs at most once under an idempotency key.
34
+ const KEYED_METHODS: ReadonlySet<string> = new Set(['session.spawn', 'session.message']);
35
+
36
+ // One routed request as it leaves for its daemon; `key` is null for an
37
+ // unkeyed request.
38
+ interface RoutedSend {
39
+ readonly m: string;
40
+ readonly route: ReturnType<typeof resolveDaemonRequest>;
41
+ readonly daemon: RegistryDaemon;
42
+ readonly required: readonly DaemonFeature[];
43
+ readonly principal: string;
44
+ readonly key: string | null;
45
+
46
+ // Picks the params of each write of the request, right before it.
47
+ readonly prepareParams?: ParamsPreparer;
48
+ }
49
+
50
+ // A keyed request on its way to a daemon: the daemon its ids or the
51
+ // default picked, and the one its `daemon` param named, if any.
52
+ interface KeyedRoute {
53
+ readonly m: string;
54
+ readonly params: Readonly<Record<string, unknown>>;
55
+ readonly key: string;
56
+ readonly picked: RegistryDaemon;
57
+ readonly named: RegistryDaemon | null;
58
+ readonly required: readonly DaemonFeature[];
59
+ readonly principal: string;
60
+ }
61
+
62
+ /**
63
+ * The gateway's caller for the MCP tools, the same shape as the caller the
64
+ * tool handlers take: each request goes to the one
65
+ * daemon its ids, its `daemon` param, or the registry's default daemon
66
+ * picks, in that order, and never to another daemon when that one fails.
67
+ * A request always acts as the principal it is given, which the HTTP layer
68
+ * takes from the verified OAuth client id, and a request without one never
69
+ * leaves. A keyed spawn or message is bound to its daemon before it is
70
+ * sent, so a retry reaches the same daemon whatever the default is by
71
+ * then. `session.list`, `agents.list`, and `events.read` ask every daemon
72
+ * in parallel and report the daemons that did not answer; `daemons.list`
73
+ * answers from the gateway itself. Every id in an answer or a daemon's
74
+ * error comes back as a gateway id.
75
+ */
76
+ export class RoutingCaller {
77
+ private readonly opts: RoutingCallerOptions;
78
+
79
+ // oxlint-disable-next-line prefer-readonly-parameter-types -- the options hold the live daemon pool and binding store
80
+ constructor(opts: RoutingCallerOptions) {
81
+ this.opts = opts;
82
+ }
83
+
84
+ sendRequest(
85
+ m: string,
86
+ p: Readonly<Record<string, unknown>> = {},
87
+ required: readonly DaemonFeature[] = [],
88
+ principal?: string,
89
+ ): Promise<Readonly<Record<string, unknown>>> {
90
+ if (principal === undefined) {
91
+ return Promise.reject(
92
+ new Error('the gateway sends a request only as the verified client of the call'),
93
+ );
94
+ }
95
+
96
+ const { daemon: named, ...params } = p;
97
+
98
+ return match(m)
99
+ .with('daemons.list', () => this.readDaemonStates())
100
+ .with('session.list', 'agents.list', (fanned) => this.sendFanOut(fanned, required, principal))
101
+ .with('events.read', () =>
102
+ readFleetEvents(
103
+ { registry: this.opts.registry, getCaller: this.getCaller, timeoutMs: this.getTimeout() },
104
+ params,
105
+ required,
106
+ principal,
107
+ ),
108
+ )
109
+ .otherwise(() => this.sendRouted(m, params, named, required, principal));
110
+ }
111
+
112
+ /**
113
+ * The features any daemon that is up announces, plus `fleet.daemons`, so
114
+ * the tool list offers an option when at least one daemon serves it and
115
+ * the inputs that pick a daemon always.
116
+ */
117
+ async readFeatures(): Promise<ReadonlySet<GatewayFeature>> {
118
+ const outcomes = await Promise.all(
119
+ [...this.opts.registry.daemons.values()].map((daemon) =>
120
+ waitForOutcome(this.opts.pool.getCaller(daemon.name).readHello(), this.getTimeout()),
121
+ ),
122
+ );
123
+
124
+ const features = new Set<GatewayFeature>(['fleet.daemons']);
125
+
126
+ for (const outcome of outcomes) {
127
+ if (outcome.kind === 'answered') {
128
+ for (const feature of outcome.value.features) {
129
+ features.add(feature);
130
+ }
131
+ }
132
+ }
133
+
134
+ return features;
135
+ }
136
+
137
+ private readonly getCaller = (name: string): DaemonCaller => this.opts.pool.getCaller(name);
138
+
139
+ private getTimeout(): number {
140
+ return this.opts.fanOutTimeoutMs ?? FAN_OUT_TIMEOUT_MS;
141
+ }
142
+
143
+ private getNow(): number {
144
+ return this.opts.now === undefined ? Date.now() : this.opts.now();
145
+ }
146
+
147
+ // The daemon a `daemon` param picks, or null when the call gives none.
148
+ private findNamedDaemon(named: unknown): RegistryDaemon | null {
149
+ if (named === undefined) {
150
+ return null;
151
+ }
152
+
153
+ const daemon = typeof named === 'string' ? this.opts.registry.daemons.get(named) : undefined;
154
+
155
+ if (daemon === undefined) {
156
+ const shown = typeof named === 'string' ? named : JSON.stringify(named);
157
+
158
+ throw new DaemonError('bad_args', `no daemon '${shown}' in this gateway`);
159
+ }
160
+
161
+ return daemon;
162
+ }
163
+
164
+ private getDefaultDaemon(): RegistryDaemon {
165
+ const daemon = this.opts.registry.daemons.get(this.opts.registry.defaultDaemon);
166
+
167
+ if (daemon === undefined) {
168
+ throw new Error('the registry default daemon is missing from the registry');
169
+ }
170
+
171
+ return daemon;
172
+ }
173
+
174
+ private sendRouted(
175
+ m: string,
176
+ params: Readonly<Record<string, unknown>>,
177
+ rawNamed: unknown,
178
+ required: readonly DaemonFeature[],
179
+ principal: string,
180
+ ): Promise<Readonly<Record<string, unknown>>> {
181
+ const named = this.findNamedDaemon(rawNamed);
182
+ const route = resolveDaemonRequest(params, this.opts.registry);
183
+
184
+ if (route.daemon !== null && named !== null && route.daemon !== named) {
185
+ throw new DaemonError(
186
+ 'bad_args',
187
+ `the call's ids belong to daemon '${route.daemon.name}', not '${named.name}'`,
188
+ );
189
+ }
190
+
191
+ const picked = route.daemon ?? named ?? this.getDefaultDaemon();
192
+ const rawKey = route.params['idempotencyKey'];
193
+ const key = typeof rawKey === 'string' && KEYED_METHODS.has(m) ? rawKey : null;
194
+
195
+ if (key === null) {
196
+ return this.sendToRoute({ m, route, daemon: picked, required, principal, key: null });
197
+ }
198
+
199
+ const keyed: KeyedRoute = { m, params: route.params, key, picked, named, required, principal };
200
+
201
+ return this.sendKeyed(keyed, route);
202
+ }
203
+
204
+ // Sends a keyed request to the daemon its binding holds. Only the first
205
+ // write under the key, across every call and gateway process, goes out
206
+ // as a normal request: right before each write the binding is marked
207
+ // sent, and a write that finds it sent already goes out replay-only, so
208
+ // the daemon answers it from the key it holds and runs nothing when it
209
+ // holds none. A gateway restart between the claim and the first write
210
+ // leaves the binding unsent, so the next call makes that first send;
211
+ // one after leaves it sent, so the next call replays. A refusal before
212
+ // anything is sent leaves no trace of the key.
213
+ private async sendKeyed(
214
+ keyed: KeyedRoute,
215
+ route: RoutedSend['route'],
216
+ ): Promise<Readonly<Record<string, unknown>>> {
217
+ const claimed = await this.claimRoute(keyed);
218
+
219
+ try {
220
+ return await this.sendToRoute({
221
+ m: keyed.m,
222
+ route,
223
+ daemon: claimed.daemon,
224
+ required: keyed.required,
225
+ principal: keyed.principal,
226
+ key: keyed.key,
227
+ prepareParams: (params) =>
228
+ this.opts.store.claimFirstSend(keyed.principal, keyed.m, keyed.key, this.getNow())
229
+ ? params
230
+ : { ...params, replayOnly: true },
231
+ });
232
+ } catch (error) {
233
+ if (error instanceof GatewayError) {
234
+ this.opts.store.removeBinding(keyed.principal, keyed.m, keyed.key, claimed.claimID);
235
+ }
236
+
237
+ throw error;
238
+ }
239
+ }
240
+
241
+ // Sends one routed request to its daemon on a connection whose handshake
242
+ // announces every feature the request relies on, and records a keyed
243
+ // request's outcome on its binding.
244
+ private async sendToRoute(send: RoutedSend): Promise<Readonly<Record<string, unknown>>> {
245
+ const store = this.opts.store;
246
+
247
+ try {
248
+ const ok = await this.opts.pool
249
+ .getCaller(send.daemon.name)
250
+ .sendRequest(send.m, send.route.params, send.principal, send.required, send.prepareParams);
251
+
252
+ if (send.key !== null) {
253
+ store.updateOutcome(send.principal, send.m, send.key, 'completed', this.getNow());
254
+ }
255
+
256
+ return buildGatewayResult(send.m, ok, send.daemon);
257
+ } catch (error) {
258
+ if (send.key !== null && error instanceof DaemonError) {
259
+ const outcome = error.code === 'outcome_unknown' ? 'uncertain' : 'completed';
260
+ const rawRef = error.data?.['effectRef'];
261
+ const effectRef = typeof rawRef === 'string' ? rawRef : null;
262
+
263
+ store.updateOutcome(send.principal, send.m, send.key, outcome, this.getNow(), effectRef);
264
+
265
+ const held = store.findBinding(send.principal, send.m, send.key);
266
+
267
+ // An unknown outcome carries the daemon's id of the effect when any
268
+ // answer under the key returned one.
269
+ if (
270
+ outcome === 'uncertain' &&
271
+ effectRef === null &&
272
+ held !== null &&
273
+ held.effectRef !== null
274
+ ) {
275
+ const known = new DaemonError(error.code, error.message, {
276
+ ...error.data,
277
+ effectRef: held.effectRef,
278
+ });
279
+
280
+ throw buildGatewayError(known, send.daemon, send.route.requestIDs);
281
+ }
282
+ }
283
+
284
+ if (error instanceof DaemonError) {
285
+ throw buildGatewayError(error, send.daemon, send.route.requestIDs);
286
+ }
287
+
288
+ throw error;
289
+ }
290
+ }
291
+
292
+ // The daemon a keyed request goes to: the one an earlier call with the
293
+ // same principal, operation, and key was bound to, or else the one the
294
+ // call picked, bound now, before anything is sent. A bound daemon whose
295
+ // state identity changed, or that left the registry, is unavailable; the
296
+ // key never goes to another daemon. Completed bindings past their
297
+ // retention go first.
298
+ private async claimRoute(
299
+ route: KeyedRoute,
300
+ ): Promise<{ readonly daemon: RegistryDaemon; readonly claimID: string }> {
301
+ const store = this.opts.store;
302
+ const now = this.getNow();
303
+
304
+ store.removeExpiredBindings(now);
305
+
306
+ const held = store.findBinding(route.principal, route.m, route.key);
307
+ const bound = held === null ? null : this.opts.registry.daemons.get(held.daemon);
308
+
309
+ if (held !== null && (bound === undefined || bound?.daemonID !== held.daemonID)) {
310
+ throw new GatewayError(
311
+ 'daemon_unavailable',
312
+ `daemon '${held.daemon}', which idempotency key '${route.key}' went to, now has another state identity`,
313
+ { daemon: held.daemon, reason: 'daemon_changed' },
314
+ );
315
+ }
316
+
317
+ if (held !== null && route.named !== null && route.named.name !== held.daemon) {
318
+ throw buildConflict(route.key, held.daemon);
319
+ }
320
+
321
+ const daemon = bound ?? route.picked;
322
+
323
+ const hello = await requireServingDaemon(this.getCaller, daemon, route.required);
324
+
325
+ const claimID = randomUUID();
326
+
327
+ const binding = store.claimBinding(
328
+ {
329
+ principal: route.principal,
330
+ operation: route.m,
331
+ key: route.key,
332
+ daemon: daemon.name,
333
+ daemonID: daemon.daemonID,
334
+ retentionMs: hello.retentionMs,
335
+ payloadHash: buildBindingPayloadHash(route.params),
336
+ claimID,
337
+ },
338
+ this.getNow(),
339
+ );
340
+
341
+ // Another gateway call may have bound the key since it was read; the
342
+ // request goes only where the stored binding points.
343
+ if (binding.daemon !== daemon.name) {
344
+ throw buildConflict(route.key, binding.daemon);
345
+ }
346
+
347
+ return { daemon, claimID };
348
+ }
349
+
350
+ // Asks every daemon at once. `session.list` merges the sessions and adds
351
+ // each daemon's state, so a daemon that did not answer reads as down,
352
+ // never as one with no sessions; `agents.list` returns each daemon's
353
+ // answer, or its state alone, under its name.
354
+ private async sendFanOut(
355
+ m: 'session.list' | 'agents.list',
356
+ required: readonly DaemonFeature[],
357
+ principal: string,
358
+ ): Promise<Readonly<Record<string, unknown>>> {
359
+ const daemons = [...this.opts.registry.daemons.values()];
360
+
361
+ const outcomes = await Promise.all(
362
+ daemons.map((daemon) =>
363
+ waitForOutcome(this.sendToDaemon(m, daemon, required, principal), this.getTimeout()),
364
+ ),
365
+ );
366
+
367
+ const answered = daemons.map((daemon, index) => ({
368
+ daemon,
369
+ outcome: outcomes[index] ?? ({ kind: 'timeout' } as const),
370
+ }));
371
+
372
+ if (m === 'agents.list') {
373
+ return {
374
+ daemons: Object.fromEntries(
375
+ answered.map((entry) => [
376
+ entry.daemon.name,
377
+ entry.outcome.kind === 'answered'
378
+ ? { state: 'up', ...entry.outcome.value }
379
+ : { state: pickDaemonState(entry.outcome) },
380
+ ]),
381
+ ),
382
+ };
383
+ }
384
+
385
+ return {
386
+ sessions: answered.flatMap((entry) => collectSessions(entry.outcome)),
387
+ daemons: answered.map((entry) => ({
388
+ name: entry.daemon.name,
389
+ state: pickDaemonState(entry.outcome),
390
+ })),
391
+ };
392
+ }
393
+
394
+ private async sendToDaemon(
395
+ m: string,
396
+ daemon: RegistryDaemon,
397
+ required: readonly DaemonFeature[],
398
+ principal: string,
399
+ ): Promise<Readonly<Record<string, unknown>>> {
400
+ const ok = await this.opts.pool.getCaller(daemon.name).sendRequest(m, {}, principal, required);
401
+
402
+ return buildGatewayResult(m, ok, daemon);
403
+ }
404
+
405
+ // Each daemon's name, state, build, pinned state identity, and features,
406
+ // read from its handshake, and the default daemon. Never an address or a
407
+ // token.
408
+ private async readDaemonStates(): Promise<Readonly<Record<string, unknown>>> {
409
+ const daemons = [...this.opts.registry.daemons.values()];
410
+
411
+ const outcomes = await Promise.all(
412
+ daemons.map((daemon) =>
413
+ waitForOutcome(this.opts.pool.getCaller(daemon.name).readHello(), this.getTimeout()),
414
+ ),
415
+ );
416
+
417
+ return {
418
+ daemons: daemons.map((daemon, index) => {
419
+ const outcome = outcomes[index] ?? ({ kind: 'timeout' } as const);
420
+ const hello = outcome.kind === 'answered' ? outcome.value : null;
421
+
422
+ return {
423
+ name: daemon.name,
424
+ state: pickDaemonState(outcome),
425
+ build: hello?.build ?? null,
426
+ daemonID: daemon.daemonID,
427
+ features: hello === null ? [] : [...hello.features].toSorted(),
428
+ };
429
+ }),
430
+ defaultDaemon: this.opts.registry.defaultDaemon,
431
+ };
432
+ }
433
+ }
434
+
435
+ function collectSessions(outcome: CallOutcome<Readonly<Record<string, unknown>>>): unknown[] {
436
+ if (outcome.kind !== 'answered') {
437
+ return [];
438
+ }
439
+
440
+ const sessions: unknown = outcome.value['sessions'];
441
+
442
+ return Array.isArray(sessions) ? sessions : [];
443
+ }
444
+
445
+ function buildConflict(key: string, daemon: string): DaemonError {
446
+ return new DaemonError(
447
+ 'idempotency_conflict',
448
+ `idempotency key '${key}' was first used on daemon '${daemon}'`,
449
+ );
450
+ }
@@ -0,0 +1,33 @@
1
+ import type { DaemonFeature } from '../protocol/daemon-features';
2
+
3
+ /**
4
+ * One daemon the gateway routes to: its logical name, the TCP address it
5
+ * listens on, the state identity pinned behind the name, the incarnation
6
+ * that identity puts in every gateway id, and the bearer token the gateway
7
+ * presents to it.
8
+ */
9
+ export interface RegistryDaemon {
10
+ readonly name: string;
11
+ readonly address: { readonly host: string; readonly port: number };
12
+ readonly daemonID: string;
13
+
14
+ // The first 8 hex digits of the pinned daemon ID.
15
+ readonly incarnation: string;
16
+ readonly token: string;
17
+ }
18
+
19
+ /**
20
+ * The daemons the gateway routes to, by name, and the one a spawn without
21
+ * a daemon goes to.
22
+ */
23
+ export interface GatewayRegistry {
24
+ readonly daemons: ReadonlyMap<string, RegistryDaemon>;
25
+ readonly defaultDaemon: string;
26
+ }
27
+
28
+ /**
29
+ * A feature the gateway announces to the tool layer: every feature an up
30
+ * daemon announces, plus `fleet.daemons`, which marks a caller that routes
31
+ * across named daemons and offers the tools and inputs that pick one.
32
+ */
33
+ export type GatewayFeature = DaemonFeature | 'fleet.daemons';
@@ -0,0 +1,38 @@
1
+ /**
2
+ * How a call settled within its time: answered with a value, failed with
3
+ * an error, or still pending when the time ran out.
4
+ */
5
+ export type CallOutcome<T> =
6
+ | { readonly kind: 'answered'; readonly value: T }
7
+ | { readonly kind: 'failed'; readonly error: unknown }
8
+ | { readonly kind: 'timeout' };
9
+
10
+ /**
11
+ * Waits for a call for at most `timeoutMs`, and never throws: a failure or
12
+ * a timeout comes back as a value. A call still pending at the timeout
13
+ * keeps running, and whatever it settles to later is dropped.
14
+ */
15
+ export async function waitForOutcome<T>(
16
+ call: Readonly<Promise<T>>,
17
+ timeoutMs: number,
18
+ ): Promise<CallOutcome<T>> {
19
+ const timeout = Promise.withResolvers<CallOutcome<T>>();
20
+
21
+ const timer = setTimeout(() => {
22
+ timeout.resolve({ kind: 'timeout' });
23
+ }, timeoutMs);
24
+
25
+ const settled = (async (): Promise<CallOutcome<T>> => {
26
+ try {
27
+ return { kind: 'answered', value: await call };
28
+ } catch (error) {
29
+ return { kind: 'failed', error };
30
+ }
31
+ })();
32
+
33
+ const outcome = await Promise.race([settled, timeout.promise]);
34
+
35
+ clearTimeout(timer);
36
+
37
+ return outcome;
38
+ }
@@ -143,8 +143,12 @@ async function answerToolCall(
143
143
  }
144
144
  }
145
145
 
146
+ // A daemon's error, or any other error that holds a lowercase protocol-style
147
+ // code, such as a router's refusal about a daemon, reads as
148
+ // `<code>: <message>`. A system error's uppercase code is already in its
149
+ // message.
146
150
  function formatToolError(error: unknown): string {
147
- if (error instanceof DaemonError) {
151
+ if (error instanceof DaemonError || hasErrorCode(error)) {
148
152
  return `${error.code}: ${error.message}`;
149
153
  }
150
154
 
@@ -155,6 +159,15 @@ function formatToolError(error: unknown): string {
155
159
  return String(error);
156
160
  }
157
161
 
162
+ function hasErrorCode(error: unknown): error is Error & { readonly code: string } {
163
+ return (
164
+ error instanceof Error &&
165
+ 'code' in error &&
166
+ typeof error.code === 'string' &&
167
+ /^[a-z][a-z_]*$/.test(error.code)
168
+ );
169
+ }
170
+
158
171
  function buildRPCResult(
159
172
  id: string | number,
160
173
  result: Readonly<Record<string, unknown>>,
@@ -1,8 +1,8 @@
1
- import type { DaemonFeature } from '../protocol/daemon-features';
2
1
  import { isRecord } from '../shared/report';
3
2
  import { buildSpawnDescriptions } from './build-spawn-descriptions';
4
3
  import type { RegisteredAgent } from './build-spawn-descriptions';
5
4
  import { MCP_TOOLS } from './mcp-tools';
5
+ import type { FleetFeature } from './types';
6
6
 
7
7
  interface MCPTool {
8
8
  readonly name: string;
@@ -26,7 +26,7 @@ interface MCPTool {
26
26
  * agents the host registers or installs.
27
27
  */
28
28
  export function buildToolList(
29
- features: ReadonlySet<DaemonFeature>,
29
+ features: ReadonlySet<FleetFeature>,
30
30
  agents: readonly RegisteredAgent[] | null,
31
31
  ): readonly MCPTool[] {
32
32
  const spawn = buildSpawnDescriptions(agents);
@@ -45,7 +45,8 @@ export function buildToolList(
45
45
 
46
46
  return [
47
47
  tool.outputSchema === undefined ||
48
- (requires.output !== undefined && !features.has(requires.output))
48
+ (requires.output !== undefined && !features.has(requires.output)) ||
49
+ (requires.outputUnless !== undefined && features.has(requires.outputUnless))
49
50
  ? {
50
51
  name: tool.name,
51
52
  description,
@@ -65,8 +66,8 @@ export function buildToolList(
65
66
 
66
67
  function buildInputSchema(
67
68
  schema: Readonly<Record<string, unknown>>,
68
- inputs: Readonly<Record<string, DaemonFeature>>,
69
- features: ReadonlySet<DaemonFeature>,
69
+ inputs: Readonly<Record<string, FleetFeature>>,
70
+ features: ReadonlySet<FleetFeature>,
70
71
  ): Readonly<Record<string, unknown>> {
71
72
  const withheld = Object.entries(inputs).flatMap(([name, feature]) =>
72
73
  features.has(feature) ? [] : [name],