@agdf/mcp-server 0.20.2 → 0.31.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/src/server.js CHANGED
@@ -1,23 +1,53 @@
1
+ import { createPreparedCallTransport, preparedTransportSchema } from './prepared-call-transport.js';
2
+ import { appendSafeReadContinuation, nextActionOutputSchema } from './safe-read-continuation.js';
3
+ import { createNextActionProjection } from './next-action.js';
4
+ import { boundStdioOutput } from './stdio-output.js';
1
5
  import { McpServer, ProtocolError, ProtocolErrorCode, fromJsonSchema } from "@modelcontextprotocol/server";
2
- import { serveStdio } from "@modelcontextprotocol/server/stdio";
6
+ import { serveStdio, StdioServerTransport } from "@modelcontextprotocol/server/stdio";
3
7
  import { AjvJsonSchemaValidator } from '@modelcontextprotocol/server/validators/ajv';
4
8
  import { randomUUID } from 'node:crypto';
5
9
  import { fileURLToPath } from 'node:url';
6
- import { DispatchExecutionError, createWorkerDispatchExecutor } from "./worker.js";
10
+ import { DispatchExecutionError } from "./worker.js";
7
11
  import { loadCockpitResource } from './cockpit-resource.js';
8
12
  import { requestDeclaresMcpAppUi, requestMcpClientInfo, requestMcpClientCapabilities, requiresMcpAppUi,
9
13
  missingMcpAppUi, capabilityTransport, modernToolUiDenial } from "./ui-capability.js";
10
14
 
11
15
  export const SERVER_NAME = "agdf-mcp";
12
16
 
13
- function toolResult(runtime, name, input, result, serialize = runtime.serialize, provider, peer, facts) {
17
+ // A folded follow-up read is a convenience. Hosts move large tool output into
18
+ // files (Claude Code from roughly 80 KB of text), which costs the model more
19
+ // round trips than a separate read, so a read is inlined only below this size.
20
+ const INLINE_READ_TEXT_BYTES = 48 * 1024;
21
+ const byteLength = value => Buffer.byteLength(typeof value === 'string' ? value : JSON.stringify(value), 'utf8');
22
+
23
+ function omittedFollowup(nextRead) {
24
+ return { ...nextRead, result: null, failure: {
25
+ code: 'read_followup_output_limit', phase: 'wire_budget', original_effect_unchanged: true,
26
+ instruction: 'The original result and receipt are unchanged. Its follow-up read was not inlined to keep this response small; run only that supplied read call separately.',
27
+ } };
28
+ }
29
+
30
+ function toolResult(runtime, name, input, result, { serialize = runtime.serialize, consume = runtime.consumeToolResult,
31
+ provider, peer, facts, preparedCalls, actions, nextRead } = {}) {
14
32
  // A generation must supply its own descriptor. Never reinterpret old results
15
33
  // with the facade's current runtime. Local owners project after postprocessing.
16
- const emit = value => {
34
+ const emit = (value, { followup, decorate = true } = {}) => {
17
35
  const projected = Object.hasOwn(value._meta ?? {}, 'agdf/tool-result') || runtime.requiresProjectedResult ? value
18
36
  : runtime.projectToolResult(name, input, value, facts);
19
- const { domain, metadata, descriptor } = runtime.consumeToolResult(name, projected);
20
- const text = serialize(domain);
37
+ const { domain, metadata, descriptor } = consume(name, projected);
38
+ // Validate and bound the canonical domain with its existing owner first.
39
+ // Additive MCP navigation has its own explicit wire projection; it is not
40
+ // passed back into a closed Core result serializer as canonical state.
41
+ const canonical = JSON.parse(serialize(domain));
42
+ const navigable = followup ? { ...canonical, next_read: followup } : canonical;
43
+ const decorated = decorate && preparedCalls ? preparedCalls.decorate(navigable) : navigable;
44
+ if (decorated.next_read?.result?.report?.material_transport?.instructions) {
45
+ const transport = decorated.next_read.result.report.material_transport;
46
+ transport.instructions = transport.instructions.replaceAll('response.structuredContent.report',
47
+ 'response.structuredContent.next_read.result.report').replaceAll('JSON.parse(response.content[0].text).report',
48
+ 'JSON.parse(response.content[0].text).next_read.result.report');
49
+ }
50
+ const text = JSON.stringify(actions ? actions.project(decorated) : decorated);
21
51
  const response = {
22
52
  content: [{ type: 'text', text: domain.presentation_delivery?.text ?? text },
23
53
  ...(descriptor.failed ? [{ type: 'text', text: descriptor.message }] : [])],
@@ -28,18 +58,29 @@ function toolResult(runtime, name, input, result, serialize = runtime.serialize,
28
58
  };
29
59
  return response;
30
60
  };
31
- let response = emit(result);
32
- if (runtime.responseLimit && Buffer.byteLength(JSON.stringify(response), 'utf8') > runtime.responseLimit) {
33
- // The fallback itself is projected, checked and measured; no recursive call.
34
- const limitFacts = { code: 'resource_limit', dispatch: 'unknown' };
35
- const failure = runtime.projectToolResult(name, input, runtime.failure('resource_limit', name, input, limitFacts), limitFacts);
36
- response = emit(failure);
37
- if (Buffer.byteLength(JSON.stringify(response), 'utf8') > runtime.responseLimit) throw Error('tool_result_invalid');
61
+ const withinLimit = response => !runtime.responseLimit || byteLength(response) <= runtime.responseLimit;
62
+ // Optional additions are dropped before the result itself is touched: first the
63
+ // inlined read, then the transport decoration (whose recovery copies can double
64
+ // large forms). Only a canonical result that alone exceeds the limit becomes
65
+ // resource_limit, so navigation can never turn a committed write into unknown.
66
+ const variants = [
67
+ ...(nextRead?.result ? [{ followup: nextRead, inlineRead: true }] : []),
68
+ ...(nextRead ? [{ followup: nextRead.result ? omittedFollowup(nextRead) : nextRead }] : []),
69
+ {}, { decorate: false },
70
+ ];
71
+ for (const { inlineRead, ...variant } of variants) {
72
+ const response = emit(result, variant);
73
+ if (withinLimit(response) && (!inlineRead || byteLength(response.content[0].text) <= INLINE_READ_TEXT_BYTES)) return response;
38
74
  }
75
+ // The fallback itself is projected, checked and measured; no recursive call.
76
+ const limitFacts = { code: 'resource_limit', dispatch: 'unknown' };
77
+ const failure = runtime.projectToolResult(name, input, runtime.failure('resource_limit', name, input, limitFacts), limitFacts);
78
+ const response = emit(failure);
79
+ if (!withinLimit(response)) throw Error('tool_result_invalid');
39
80
  return response;
40
81
  }
41
82
 
42
- export function buildAgdfServer({ runtime, executor, era } = {}) {
83
+ export function buildAgdfServer({ runtime, executor, era, preparedCallStore } = {}) {
43
84
  if (!runtime?.definition || typeof runtime.serialize !== "function") {
44
85
  throw new TypeError("AGDF MCP runtime is required");
45
86
  }
@@ -47,6 +88,15 @@ export function buildAgdfServer({ runtime, executor, era } = {}) {
47
88
  throw new TypeError("AGDF MCP owned runtime is required");
48
89
  }
49
90
  const tools = runtime.tools ?? [{ name: runtime.definition.name, definition: runtime.definition, parse: runtime.parse, execute: runtime.execute }];
91
+ // Discovery and retained reads share the original SDK validators. Compile
92
+ // lazily; constructing the transport never validates or executes a request.
93
+ const schemaProvider = new AjvJsonSchemaValidator();
94
+ const deferredSchemas = { getValidator(schema) {
95
+ let validate;
96
+ return value => (validate ??= schemaProvider.getValidator(schema))(value);
97
+ } };
98
+ const inspectTool = tools.find(tool => tool.name === 'agdf_inspect');
99
+ const validateInspect = inspectTool && deferredSchemas.getValidator(inspectTool.definition.inputSchema);
50
100
  const dispatchExecutor = executor ?? {
51
101
  execute: async (argumentsValue, { toolName, signal } = {}) => {
52
102
  const tool = tools.find((entry) => entry.name === (toolName ?? runtime.definition.name));
@@ -58,7 +108,14 @@ export function buildAgdfServer({ runtime, executor, era } = {}) {
58
108
  },
59
109
  close: async () => runtime.close?.(),
60
110
  };
61
- const registrations = new Map();
111
+ const registrations = new Map(), preparedCalls = createPreparedCallTransport({
112
+ store: preparedCallStore,
113
+ // A read reference retains a supplied canonical request, never an inferred
114
+ // target. Validate the unwrapped arguments with the original SDK schema.
115
+ validateRead: call => inspectTool?.definition.annotations?.readOnlyHint === true
116
+ && validateInspect(call.arguments).valid,
117
+ }),
118
+ actions = createNextActionProjection({ isAvailable: call => preparedCalls.isAvailable(call) });
62
119
  class AgdfServer extends McpServer {
63
120
  async connect(transport) {
64
121
  return super.connect(capabilityTransport(transport, message => modernToolUiDenial(message,
@@ -67,7 +124,8 @@ export function buildAgdfServer({ runtime, executor, era } = {}) {
67
124
  }
68
125
  const server = new AgdfServer(
69
126
  { name: SERVER_NAME, version: runtime.trustedContext.expectedVersion },
70
- { capabilities: { tools: {}, ...(runtime.ui ? { resources: {} } : {}) } },
127
+ { capabilities: { tools: {}, ...(runtime.ui ? { resources: {} } : {}) },
128
+ instructions: 'Use the returned next_action.call as the normal path for reads and writes. Fill only its named inputs from actual authored content, review or a new deliberate reply; ready calls retain inputs:{} and are forwarded unchanged. The adapter retains the exact target, Run, revision, selectors and original operation identity. Do not reconstruct raw reads or status requests, retype references or use cloning helpers. Owned plugin references retain their original lifetime across reconnects at the same runtime; other connections use explicit recovery_only instructions and the saved original request. A safe inspect may already be included as next_read.result; consume it without repeating it. next_read.tool/arguments describe that executed read; next_read.call is its supplied retry only. A terminal action stops work. No reference or continuation grants authority or approval.' },
71
129
  );
72
130
  // Capability checks guard execution and resource access. Codex must first
73
131
  // discover the plugin tool metadata before selecting its UI-capable route.
@@ -80,32 +138,76 @@ export function buildAgdfServer({ runtime, executor, era } = {}) {
80
138
  connection_id: randomUUID(), server_module: fileURLToPath(import.meta.url),
81
139
  version: runtime.trustedContext.expectedVersion,
82
140
  }) : undefined;
83
- const respond = (name, input, result, serialize, context, facts) => toolResult(runtime, name, input, result, serialize, provider, provider ? {
84
- client_info: requestMcpClientInfo(server.server, context), capabilities: requestMcpClientCapabilities(server.server, context),
85
- } : undefined, facts);
141
+ // The handler validates a result before choosing its follow-up read; the
142
+ // response variants then reuse that validation instead of repeating it.
143
+ const consumed = new WeakMap();
144
+ const consume = (name, value) => {
145
+ if (!consumed.has(value)) consumed.set(value, runtime.consumeToolResult(name, value));
146
+ return consumed.get(value);
147
+ };
148
+ const respond = (name, input, result, serialize, context, facts, nextRead) => toolResult(runtime, name, input, result, {
149
+ serialize, consume, provider, facts, preparedCalls, actions, nextRead, peer: provider ? {
150
+ client_info: requestMcpClientInfo(server.server, context), capabilities: requestMcpClientCapabilities(server.server, context),
151
+ } : undefined });
152
+ // Companion events (SD SDD-006): only the process that executed a call journals its final result.
153
+ // Behind the generation facade (projected results) the generation has journaled every result it
154
+ // produced; a failure thrown here (lost connection, unknown response, unavailable generation,
155
+ // expired session) never reached a generation result, so the facade journals it itself.
156
+ const journaling = () => !runtime.requiresProjectedResult && typeof runtime.observeOutcome === 'function';
157
+ const journalingFailures = () => typeof runtime.observeOutcome === 'function';
158
+ const journal = (name, input, projected) => {
159
+ try { runtime.observeOutcome(name, input, projected); } catch { /* notification only */ }
160
+ };
86
161
  const uiAllowed = (context) => !gated || requestDeclaresMcpAppUi(server.server, context, runtime.ui.mimeType);
87
162
  const supportsUi = context => !!runtime.ui && requestDeclaresMcpAppUi(server.server, context, runtime.ui.mimeType);
88
- // Discovery needs the exact schemas, but does not execute any tool. Compile
89
- // each validator on its first validation, using the same SDK provider/options.
90
- // Input validation still precedes execution; successful output is still checked.
91
- const schemaProvider = new AjvJsonSchemaValidator();
92
- const deferredSchemas = { getValidator(schema) {
93
- let validate;
94
- return value => (validate ??= schemaProvider.getValidator(schema))(value);
95
- } };
163
+ const nextRead = async (result, context) => appendSafeReadContinuation(result, {
164
+ allowed: !context.signal?.aborted && !!inspectTool && inspectTool.definition.annotations?.readOnlyHint === true
165
+ && uiAllowed(context) && (!inspectTool.requiresUi || supportsUi(context)),
166
+ validate: call => ['working_directory', 'presentation_language', 'primary_target', 'target_source']
167
+ .every(key => typeof call.arguments[key] === 'string' && call.arguments[key].length)
168
+ && validateInspect(call.arguments).valid,
169
+ execute: async call => {
170
+ const name = call.tool;
171
+ const input = runtime.prepareInput?.(name, call.arguments) ?? call.arguments;
172
+ const parentOwned = typeof inspectTool.parentOwned === 'function' ? inspectTool.parentOwned(input) : inspectTool.parentOwned;
173
+ let observed = parentOwned
174
+ ? await inspectTool.execute(inspectTool.parse(input), context.signal,
175
+ { client: requestMcpClientInfo(server.server, context), uiSupported: supportsUi(context) })
176
+ : await dispatchExecutor.execute(input, { signal: context.signal, toolName: name,
177
+ trusted: runtime.prepareDispatch?.(name, input) });
178
+ if (runtime.postprocessToolResult) observed = await runtime.postprocessToolResult(name, input, observed,
179
+ { uiSupported: supportsUi(context), client: requestMcpClientInfo(server.server, context) });
180
+ const projected = runtime.projectToolResult(name, input, observed);
181
+ const { domain } = runtime.consumeToolResult(name, projected);
182
+ return JSON.parse((inspectTool.serialize ?? runtime.serialize)(domain));
183
+ },
184
+ });
96
185
  for (const tool of tools) {
97
186
  const { name, definition, serialize } = tool;
98
- const schema = fromJsonSchema(definition.inputSchema, deferredSchemas);
187
+ const schema = fromJsonSchema(preparedTransportSchema(name, definition.inputSchema), { getValidator(inputSchema) {
188
+ const validate = deferredSchemas.getValidator(inputSchema);
189
+ return input => {
190
+ const checked = validate(input);
191
+ if (checked.valid) return checked;
192
+ const preparedHint = preparedCalls.validationHint(name, input);
193
+ // Validation has already failed. The exact transport correction is more
194
+ // useful here than every unrelated alternative in the raw-command union.
195
+ if (preparedHint) return { ...checked, errorMessage: preparedHint };
196
+ const hint = runtime.inputValidationHint?.(name, input);
197
+ return hint ? { ...checked, errorMessage: `${hint} Schema: ${checked.errorMessage}` } : checked;
198
+ };
199
+ } });
99
200
  const registered = server.registerTool(
100
201
  name,
101
202
  {
102
203
  description: definition.description,
103
204
  annotations: definition.annotations,
104
205
  inputSchema: schema,
105
- ...(definition.outputSchema ? { outputSchema: fromJsonSchema(definition.outputSchema, deferredSchemas) } : {}),
206
+ ...(definition.outputSchema ? { outputSchema: fromJsonSchema(nextActionOutputSchema(name, definition.outputSchema), deferredSchemas) } : {}),
106
207
  ...(definition._meta ? { _meta: definition._meta } : {}),
107
208
  },
108
209
  async (argumentsValue, context) => {
210
+ const started = performance.now();
109
211
  const denied = !uiAllowed(context) ? runtime.unsupportedUiCode ?? 'resource_denied'
110
212
  : tool.requiresUi && !supportsUi(context) ? 'host_ui_unsupported' : null;
111
213
  if (denied) {
@@ -113,23 +215,40 @@ export function buildAgdfServer({ runtime, executor, era } = {}) {
113
215
  const failure = runtime.projectToolResult(name, argumentsValue, runtime.failure(denied, name, argumentsValue, facts), facts);
114
216
  return respond(name, argumentsValue, failure, serialize ?? runtime.serialize, context, facts);
115
217
  }
116
- let phase = tool.parentOwned ? 'parse' : 'execute';
218
+ let parentOwned;
219
+ let phase = 'parse';
117
220
  try {
221
+ phase = 'parse';
222
+ argumentsValue = preparedCalls.resolve(name, argumentsValue);
223
+ parentOwned = typeof tool.parentOwned === 'function' ? tool.parentOwned(argumentsValue) : tool.parentOwned;
224
+ argumentsValue = runtime.prepareInput?.(name, argumentsValue) ?? argumentsValue;
225
+ phase = parentOwned ? 'parse' : 'execute';
118
226
  let result;
119
- if (tool.parentOwned) {
227
+ if (parentOwned) {
120
228
  const parsed = tool.parse(argumentsValue); phase = 'execute';
121
229
  result = await tool.execute(parsed, context.signal,
122
230
  { client: requestMcpClientInfo(server.server, context), uiSupported: supportsUi(context) });
123
- } else result = await dispatchExecutor.execute(argumentsValue, { signal: context.signal, toolName: name });
231
+ } else result = await dispatchExecutor.execute(argumentsValue, { signal: context.signal, toolName: name,
232
+ trusted: runtime.prepareDispatch?.(name, argumentsValue) });
124
233
  if (runtime.postprocessToolResult) result = await runtime.postprocessToolResult(name, argumentsValue, result,
125
234
  { uiSupported: supportsUi(context), client: requestMcpClientInfo(server.server, context) });
126
- return respond(name, argumentsValue, result, serialize ?? runtime.serialize, context);
235
+ // Validate the producer and its canonical domain before consuming any
236
+ // supplied continuation. Malformed output must not start another read.
237
+ const checked = Object.hasOwn(result._meta ?? {}, 'agdf/tool-result') || runtime.requiresProjectedResult ? result
238
+ : runtime.projectToolResult(name, argumentsValue, result);
239
+ const { domain } = consume(name, checked);
240
+ if (journaling()) journal(name, argumentsValue, checked);
241
+ const continued = await nextRead(domain, context);
242
+ return respond(name, argumentsValue, checked, serialize ?? runtime.serialize, context, undefined, continued.next_read);
127
243
  } catch (error) {
128
244
  const facts = runtime.normalizeFailure(error, { phase });
129
- const failure = runtime.failure(facts.code, name, argumentsValue, facts);
245
+ const help = phase === 'parse' ? preparedCalls.validationHint(name, argumentsValue) : '';
246
+ const failure = { ...runtime.failure(facts.code, name, argumentsValue, { ...facts, elapsed_ms: performance.now() - started }),
247
+ ...(help ? { prepared_call_help: help } : {}) };
130
248
  // A local execution/delivery failure is owned here, including malformed
131
249
  // producer output. It never causes re-execution or a second write.
132
250
  const projected = runtime.projectToolResult(name, argumentsValue, failure, facts);
251
+ if (journalingFailures()) journal(name, argumentsValue, projected);
133
252
  return respond(name, argumentsValue, projected, serialize ?? runtime.serialize, context, facts);
134
253
  }
135
254
  },
@@ -151,7 +270,7 @@ export function buildAgdfServer({ runtime, executor, era } = {}) {
151
270
  const listedTools = tools.map(({ name, definition }) => ({
152
271
  name, description: definition.description, inputSchema: server.toolInputSchemaJson(name),
153
272
  annotations: definition.annotations, ...(definition._meta ? { _meta: definition._meta } : {}),
154
- ...(definition.outputSchema ? { outputSchema: definition.outputSchema } : {}),
273
+ ...(definition.outputSchema ? { outputSchema: nextActionOutputSchema(name, definition.outputSchema) } : {}),
155
274
  }));
156
275
  const listedResource = { uri: resource.uri, name: runtime.ui.label ?? 'AGDF Cockpit', mimeType: resource.mimeType, _meta: resource._meta };
157
276
  server.server.removeRequestHandler("tools/list");
@@ -162,18 +281,19 @@ export function buildAgdfServer({ runtime, executor, era } = {}) {
162
281
  }
163
282
  }
164
283
  return Object.assign(server, {
165
- closeAgdfRuntime: async () => { await dispatchExecutor.close(); return runtime.close?.(); },
284
+ closeAgdfRuntime: async () => { preparedCalls.close(); actions.close(); await dispatchExecutor.close(); return runtime.close?.(); },
166
285
  });
167
286
  }
168
287
 
169
- export function serveAgdfStdio({ runtime, executor, onerror } = {}) {
288
+ export function serveAgdfStdio({ runtime, executor, onerror, preparedCallStore } = {}) {
170
289
  const instances = new Set();
171
290
  const handle = serveStdio(({ era }) => {
172
- const server = buildAgdfServer({ runtime, executor, era });
291
+ const server = buildAgdfServer({ runtime, executor, era, preparedCallStore });
173
292
  instances.add(server);
174
293
  return server;
175
294
  }, {
176
295
  legacy: "serve",
296
+ transport: boundStdioOutput(new StdioServerTransport()),
177
297
  onerror: () => onerror?.("AGDF_MCP_TRANSPORT_ERROR"),
178
298
  });
179
299
  return Object.freeze({
@@ -190,11 +310,4 @@ export function serveAgdfStdio({ runtime, executor, onerror } = {}) {
190
310
  });
191
311
  }
192
312
 
193
- export function createAgdfWorkerExecutor(runtime, options = {}) {
194
- return createWorkerDispatchExecutor({
195
- surface: runtime.trustedContext.surface,
196
- expectedVersion: runtime.trustedContext.expectedVersion,
197
- commandGroups: runtime.trustedContext.commandGroups,
198
- ...options,
199
- });
200
- }
313
+ export { createAgdfWorkerExecutor } from './worker.js';
@@ -0,0 +1,42 @@
1
+ import { OWNER_LIMITS } from './generation-protocol.js';
2
+
3
+ // One entry-owned SDK wire, one active write. Concurrent SDK responses must not
4
+ // attach one drain/error listener per result to a blocked native Worker stream.
5
+ // Capture exact JSON at admission. Existing result/resource owners bound the
6
+ // one active message; the additional queue has its own strict byte/frame cap.
7
+ // A legal large UI response must still work without lowering its resource cap.
8
+ export function boundStdioOutput(transport) {
9
+ const originalSend = transport.send.bind(transport), originalClose = transport.close.bind(transport);
10
+ const queue = [];
11
+ let active, queuedBytes = 0, closed = false, closing;
12
+ const capacityError = () => Error('AGDF_MCP_STDIO_CAPACITY');
13
+ function pump() {
14
+ if (active || closed || !queue.length) return;
15
+ const entry = active = queue.shift(); queuedBytes -= entry.bytes;
16
+ Promise.resolve().then(() => originalSend(entry.message)).then(entry.resolve, entry.reject).finally(() => {
17
+ active = undefined; pump();
18
+ });
19
+ }
20
+ transport.send = message => {
21
+ if (closed) return Promise.reject(Error('AGDF_MCP_STDIO_CLOSED'));
22
+ let snapshot, size;
23
+ try {
24
+ const json = JSON.stringify(message); size = Buffer.byteLength(json) + 1;
25
+ if (queue.length + (active ? 1 : 0) >= OWNER_LIMITS.frames || active && queuedBytes + size > OWNER_LIMITS.bytes) return Promise.reject(capacityError());
26
+ snapshot = JSON.parse(json); // Preserve SDK JSON semantics and call-time bytes.
27
+ } catch (error) { return Promise.reject(error); }
28
+ return new Promise((resolve, reject) => {
29
+ queuedBytes += size; queue.push({ message: snapshot, bytes: size, resolve, reject }); pump();
30
+ });
31
+ };
32
+ transport.close = () => {
33
+ if (closing) return closing;
34
+ closed = true;
35
+ for (const entry of queue.splice(0)) { queuedBytes -= entry.bytes; entry.reject(Error('AGDF_MCP_STDIO_CLOSED')); }
36
+ // Close does not prove that an active write drained. Its SDK promise stays
37
+ // observed; genuine Worker exit is still required by the lifecycle owner.
38
+ active?.reject(Error('AGDF_MCP_STDIO_CLOSED'));
39
+ closing = Promise.resolve().then(originalClose); return closing;
40
+ };
41
+ return transport;
42
+ }
@@ -1,6 +1,6 @@
1
1
  import { subscribe } from "node:diagnostics_channel";
2
2
  import { parentPort, workerData } from "node:worker_threads";
3
- import { OWNED_LOCK_CHANNEL, createMcpDispatchRuntime } from "create-agdf/mcp-dispatch-runtime";
3
+ import { OWNED_LOCK_CHANNEL, createMcpDispatchRuntime } from "#agdf-dispatch-worker";
4
4
  import { ownedLockForwarder } from "./worker.js";
5
5
 
6
6
  function send(message) {
@@ -14,7 +14,7 @@ subscribe(OWNED_LOCK_CHANNEL, ownedLockForwarder(send));
14
14
  let runtime, phase = 'setup';
15
15
  try {
16
16
  send({ type: 'phase', phase: 'runtime_provenance' });
17
- runtime = createMcpDispatchRuntime({ surface: workerData.surface, ...(workerData.commandGroups !== undefined ? { commandGroups: workerData.commandGroups } : {}) });
17
+ runtime = createMcpDispatchRuntime({ surface: workerData.surface, materialGrants: workerData.materialGrants, inputContext: workerData.inputContext, ...(workerData.commandGroups !== undefined ? { commandGroups: workerData.commandGroups } : {}) });
18
18
  if (runtime.trustedContext.provenanceStatus !== "matched") {
19
19
  send({ type: "failure", code: "runtime_provenance_invalid", dispatch: "not_dispatched" });
20
20
  } else if (runtime.trustedContext.expectedVersion !== workerData.expectedVersion) {
package/src/worker.js CHANGED
@@ -30,21 +30,30 @@ export function createWorkerDispatchExecutor({
30
30
  // Reserve up to one second instead of beginning shutdown at the deadline.
31
31
  const terminationReserveMs = Math.min(1_000, Math.max(1, Math.floor(timeoutMs / 4)));
32
32
  let active = null;
33
+ let closed = false;
34
+ const waiting = [];
35
+ const rejectWaiting = error => { for (const job of waiting.splice(0)) { job.cleanup(); job.reject(error); } };
36
+ const drain = () => {
37
+ if (active || closed || !waiting.length) return;
38
+ const job = waiting.shift(); job.cleanup();
39
+ execute(job.argumentsValue, { ...job.options, deadline: job.deadline }).then(job.resolve, job.reject).finally(drain);
40
+ };
33
41
  const event = (phase, started) => {
34
42
  try { onEvent({ phase, elapsed_ms: performance.now() - started }); } catch {}
35
43
  };
36
- return Object.freeze({
37
- async execute(argumentsValue, { signal, toolName } = {}) {
44
+ const execute = async (argumentsValue, { signal, toolName, trusted, deadline: queuedDeadline } = {}) => {
38
45
  if (active) throw new DispatchExecutionError("dispatch_busy", 'not_dispatched');
39
46
  if (signal?.aborted) throw new DispatchExecutionError("dispatch_cancelled", 'not_dispatched');
40
47
  return new Promise((resolve, reject) => {
41
48
  let settled = false, exited = false, completion, workerPhase = 'worker_start';
42
49
  let confirmExit;
43
50
  const actualExit = new Promise(resolve => { confirmExit = resolve; });
44
- const started = performance.now(), deadline = started + timeoutMs;
51
+ const started = performance.now(), deadline = queuedDeadline ?? started + timeoutMs;
52
+ const budget = deadline - started;
53
+ if (budget <= 0) { reject(new DispatchExecutionError('dispatch_timeout', 'not_dispatched')); return; }
45
54
  let worker;
46
55
  try { worker = new WorkerClass(WORKER_URL, {
47
- workerData: { surface, expectedVersion, commandGroups, argumentsValue, toolName: toolName ?? null },
56
+ workerData: { surface, expectedVersion, commandGroups, argumentsValue, toolName: toolName ?? null, materialGrants: trusted?.materialGrants, inputContext: trusted?.inputContext },
48
57
  }); } catch { reject(new DispatchExecutionError('dispatch_worker_failed', 'not_dispatched')); return; }
49
58
  event('worker_start', started);
50
59
  const current = { worker, timer: null, cleanupSignal: null, cancel: () => finish(reject, new DispatchExecutionError("dispatch_cancelled")) };
@@ -62,7 +71,7 @@ export function createWorkerDispatchExecutor({
62
71
  try { onEvent({ phase: 'locks_released', elapsed_ms: performance.now() - started, count: released }); } catch {}
63
72
  };
64
73
  // Called only after actual exit or confirmed termination: the thread no longer runs.
65
- const release = () => { releaseHeldLocks(); if (active === current) active = null; };
74
+ const release = () => { releaseHeldLocks(); if (active === current) { active = null; queueMicrotask(drain); } };
66
75
  const finish = (callback, value) => {
67
76
  if (settled) return completion;
68
77
  settled = true;
@@ -87,6 +96,8 @@ export function createWorkerDispatchExecutor({
87
96
  } else {
88
97
  // A late actual exit may release capacity; rejection alone never proves termination.
89
98
  event('termination_unconfirmed', started);
99
+ current.unconfirmed = true;
100
+ rejectWaiting(new DispatchExecutionError('dispatch_busy', 'not_dispatched'));
90
101
  reject(new DispatchExecutionError('dispatch_worker_termination_unconfirmed', 'unknown', undefined, {
91
102
  ...(callback === reject && value instanceof DispatchExecutionError ? { cause_code: value.code } : {}),
92
103
  worker_phase: workerPhase,
@@ -100,7 +111,8 @@ export function createWorkerDispatchExecutor({
100
111
  current.cleanupSignal = () => signal.removeEventListener("abort", cancel);
101
112
  signal.addEventListener("abort", cancel, { once: true });
102
113
  }
103
- current.timer = setTimeout(() => finish(reject, new DispatchExecutionError("dispatch_timeout")), timeoutMs - terminationReserveMs);
114
+ current.timer = setTimeout(() => finish(reject, new DispatchExecutionError("dispatch_timeout")),
115
+ budget - Math.min(terminationReserveMs, Math.max(1, Math.floor(budget / 4))));
104
116
  worker.on("message", message => {
105
117
  // Lock messages also arrive after settlement while the worker drains.
106
118
  if (message?.type === 'lock') {
@@ -127,8 +139,42 @@ export function createWorkerDispatchExecutor({
127
139
  // Cover an abort racing listener installation.
128
140
  if (signal?.aborted) cancel();
129
141
  });
142
+ };
143
+ return Object.freeze({
144
+ execute(argumentsValue, options = {}) {
145
+ if (closed) return Promise.reject(new DispatchExecutionError(active ? 'dispatch_busy' : 'dispatch_cancelled', 'not_dispatched'));
146
+ if (options.signal?.aborted) return Promise.reject(new DispatchExecutionError('dispatch_cancelled', 'not_dispatched'));
147
+ const deadline = performance.now() + timeoutMs;
148
+ if (!active && !waiting.length) return execute(argumentsValue, { ...options, deadline });
149
+ // Only immutable Inspect requests wait. Commands still report contention;
150
+ // no write is replayed, parallelized or admitted after unknown termination.
151
+ if (options.toolName !== 'agdf_inspect' || active?.unconfirmed)
152
+ return Promise.reject(new DispatchExecutionError('dispatch_busy', 'not_dispatched'));
153
+ if (waiting.length >= 8) return Promise.reject(new DispatchExecutionError('resource_limit', 'not_dispatched'));
154
+ return new Promise((resolve, reject) => {
155
+ const job = { argumentsValue, options, deadline, resolve, reject };
156
+ const remove = error => {
157
+ const index = waiting.indexOf(job);
158
+ if (index < 0) return;
159
+ waiting.splice(index, 1); job.cleanup(); reject(error);
160
+ };
161
+ const abort = () => remove(new DispatchExecutionError('dispatch_cancelled', 'not_dispatched'));
162
+ const timer = setTimeout(() => remove(new DispatchExecutionError('dispatch_timeout', 'not_dispatched')), timeoutMs);
163
+ job.cleanup = () => { clearTimeout(timer); options.signal?.removeEventListener('abort', abort); };
164
+ waiting.push(job); options.signal?.addEventListener('abort', abort, { once: true });
165
+ if (options.signal?.aborted) abort();
166
+ });
130
167
  },
131
- async close() { await active?.cancel(); },
168
+ async close() { closed = true; rejectWaiting(new DispatchExecutionError('dispatch_cancelled', 'not_dispatched')); await active?.cancel(); },
132
169
  get active() { return Boolean(active); },
133
170
  });
134
171
  }
172
+
173
+ export function createAgdfWorkerExecutor(runtime, options = {}) {
174
+ return createWorkerDispatchExecutor({
175
+ surface: runtime.trustedContext.surface,
176
+ expectedVersion: runtime.trustedContext.expectedVersion,
177
+ commandGroups: runtime.trustedContext.commandGroups,
178
+ ...options,
179
+ });
180
+ }