@sentropic/h2a 0.97.2 → 0.97.4

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 (94) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/dist/bin.js +13 -0
  4. package/dist/bin.js.map +1 -1
  5. package/dist/cli.d.ts +13 -0
  6. package/dist/cli.d.ts.map +1 -1
  7. package/dist/cli.js +94 -37
  8. package/dist/cli.js.map +1 -1
  9. package/dist/index.d.ts +1 -1
  10. package/dist/mcp.d.ts +1 -1
  11. package/dist/mcp.d.ts.map +1 -1
  12. package/dist/mcp.js +2 -0
  13. package/dist/mcp.js.map +1 -1
  14. package/dist/runtime/identity/bindings.d.ts +33 -0
  15. package/dist/runtime/identity/bindings.d.ts.map +1 -1
  16. package/dist/runtime/identity/bindings.js +65 -2
  17. package/dist/runtime/identity/bindings.js.map +1 -1
  18. package/dist/runtime/identity/index.d.ts +4 -4
  19. package/dist/runtime/identity/index.d.ts.map +1 -1
  20. package/dist/runtime/identity/index.js +2 -2
  21. package/dist/runtime/identity/index.js.map +1 -1
  22. package/dist/runtime/identity/live.d.ts +63 -0
  23. package/dist/runtime/identity/live.d.ts.map +1 -1
  24. package/dist/runtime/identity/live.js +346 -65
  25. package/dist/runtime/identity/live.js.map +1 -1
  26. package/dist/runtime/identity/worker.d.ts +19 -0
  27. package/dist/runtime/identity/worker.d.ts.map +1 -0
  28. package/dist/runtime/identity/worker.js +189 -0
  29. package/dist/runtime/identity/worker.js.map +1 -0
  30. package/dist/runtime/local-files/locks.d.ts +34 -0
  31. package/dist/runtime/local-files/locks.d.ts.map +1 -1
  32. package/dist/runtime/local-files/locks.js +55 -2
  33. package/dist/runtime/local-files/locks.js.map +1 -1
  34. package/dist/runtime/local-files/store.d.ts +21 -0
  35. package/dist/runtime/local-files/store.d.ts.map +1 -1
  36. package/dist/runtime/local-files/store.js +79 -10
  37. package/dist/runtime/local-files/store.js.map +1 -1
  38. package/dist/runtime/mcp/discovery-pagination.d.ts +73 -0
  39. package/dist/runtime/mcp/discovery-pagination.d.ts.map +1 -0
  40. package/dist/runtime/mcp/discovery-pagination.js +446 -0
  41. package/dist/runtime/mcp/discovery-pagination.js.map +1 -0
  42. package/dist/runtime/mcp/frame-budget.d.ts +149 -0
  43. package/dist/runtime/mcp/frame-budget.d.ts.map +1 -0
  44. package/dist/runtime/mcp/frame-budget.js +275 -0
  45. package/dist/runtime/mcp/frame-budget.js.map +1 -0
  46. package/dist/runtime/mcp/handlers.d.ts +14 -3
  47. package/dist/runtime/mcp/handlers.d.ts.map +1 -1
  48. package/dist/runtime/mcp/handlers.js +22 -1
  49. package/dist/runtime/mcp/handlers.js.map +1 -1
  50. package/dist/runtime/mcp/identity-state.d.ts +131 -0
  51. package/dist/runtime/mcp/identity-state.d.ts.map +1 -0
  52. package/dist/runtime/mcp/identity-state.js +288 -0
  53. package/dist/runtime/mcp/identity-state.js.map +1 -0
  54. package/dist/runtime/mcp/index.d.ts +2 -0
  55. package/dist/runtime/mcp/index.d.ts.map +1 -1
  56. package/dist/runtime/mcp/index.js +2 -0
  57. package/dist/runtime/mcp/index.js.map +1 -1
  58. package/dist/runtime/mcp/notifications.d.ts +13 -1
  59. package/dist/runtime/mcp/notifications.d.ts.map +1 -1
  60. package/dist/runtime/mcp/notifications.js +36 -20
  61. package/dist/runtime/mcp/notifications.js.map +1 -1
  62. package/dist/runtime/mcp/payload-store.d.ts +67 -0
  63. package/dist/runtime/mcp/payload-store.d.ts.map +1 -0
  64. package/dist/runtime/mcp/payload-store.js +242 -0
  65. package/dist/runtime/mcp/payload-store.js.map +1 -0
  66. package/dist/runtime/mcp/phase-trace.d.ts +98 -0
  67. package/dist/runtime/mcp/phase-trace.d.ts.map +1 -0
  68. package/dist/runtime/mcp/phase-trace.js +179 -0
  69. package/dist/runtime/mcp/phase-trace.js.map +1 -0
  70. package/dist/runtime/mcp/server.d.ts +36 -1
  71. package/dist/runtime/mcp/server.d.ts.map +1 -1
  72. package/dist/runtime/mcp/server.js +162 -5
  73. package/dist/runtime/mcp/server.js.map +1 -1
  74. package/dist/runtime/mcp/sessions.d.ts +12 -2
  75. package/dist/runtime/mcp/sessions.d.ts.map +1 -1
  76. package/dist/runtime/mcp/sessions.js +15 -3
  77. package/dist/runtime/mcp/sessions.js.map +1 -1
  78. package/dist/runtime/mcp/stdio.d.ts +21 -0
  79. package/dist/runtime/mcp/stdio.d.ts.map +1 -1
  80. package/dist/runtime/mcp/stdio.js +378 -98
  81. package/dist/runtime/mcp/stdio.js.map +1 -1
  82. package/dist/runtime/mcp/tools.d.ts.map +1 -1
  83. package/dist/runtime/mcp/tools.js +47 -4
  84. package/dist/runtime/mcp/tools.js.map +1 -1
  85. package/dist/runtime/mcp-central.d.ts.map +1 -1
  86. package/dist/runtime/mcp-central.js +17 -7
  87. package/dist/runtime/mcp-central.js.map +1 -1
  88. package/dist/runtime/mcp-http/hosted-mcp-server.d.ts.map +1 -1
  89. package/dist/runtime/mcp-http/hosted-mcp-server.js +16 -6
  90. package/dist/runtime/mcp-http/hosted-mcp-server.js.map +1 -1
  91. package/dist/runtime/mcp-http/readonly-allowlist.d.ts.map +1 -1
  92. package/dist/runtime/mcp-http/readonly-allowlist.js +6 -0
  93. package/dist/runtime/mcp-http/readonly-allowlist.js.map +1 -1
  94. package/package.json +3 -3
@@ -1,13 +1,17 @@
1
1
  import { createInterface } from "node:readline";
2
2
  import { spawnSync } from "node:child_process";
3
3
  import { randomUUID } from "node:crypto";
4
- import { linkSync, unlinkSync, writeFileSync } from "node:fs";
4
+ import { linkSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
5
5
  import { createInboxWakeHandler } from "../drive/inbox-wake.js";
6
6
  import { detectLocalLaunchContext, detectTmuxLaunchContext } from "../drive/index.js";
7
7
  import { createLocalStore } from "../local-files/index.js";
8
8
  import { reapDeadInstancePresence } from "../local-files/presence.js";
9
9
  import { agentVersion } from "../version/agent-version.js";
10
+ import { currentCliVersion } from "../upgrade/index.js";
11
+ import { getActiveMcpTrace } from "./phase-trace.js";
10
12
  import { createMcpServer, isMcpTransportResult } from "./server.js";
13
+ import { createIdentityController } from "./identity-state.js";
14
+ import { boundNotificationFrame, boundResponseFrame, buildInvalidRequestId, encodeFrame, isAcceptableRequestId } from "./frame-budget.js";
11
15
  function envInt(name) {
12
16
  const raw = process.env[name];
13
17
  if (!raw)
@@ -17,7 +21,23 @@ function envInt(name) {
17
21
  }
18
22
  const PROTOCOL_VERSION = "2025-06-18";
19
23
  const SERVER_NAME = "@sentropic/h2a";
20
- const SERVER_VERSION = "0.1.1";
24
+ // The MCP `serverInfo.version` MUST be the real package version, not a frozen
25
+ // literal — a stale `0.1.1` made `initialize` disagree with the plugin manifest
26
+ // and `h2a --version`, defeating the version-drift diagnosis (#275). Resolved
27
+ // from package.json via `currentCliVersion()` and cached (initialize is rare,
28
+ // but the read is trivial and total — it falls back to "0.0.0" on any error).
29
+ let cachedServerVersion;
30
+ function serverVersion() {
31
+ if (cachedServerVersion === undefined) {
32
+ try {
33
+ cachedServerVersion = currentCliVersion();
34
+ }
35
+ catch {
36
+ cachedServerVersion = "0.0.0";
37
+ }
38
+ }
39
+ return cachedServerVersion;
40
+ }
21
41
  function currentTmuxSessionForSidecar() {
22
42
  const pane = process.env.TMUX_PANE;
23
43
  if (!pane || !/^%\d+$/.test(pane))
@@ -67,9 +87,6 @@ function publishReadinessAck(readiness, sessionId) {
67
87
  }
68
88
  }
69
89
  }
70
- function writeResponse(stdout, response) {
71
- stdout.write(`${JSON.stringify(response)}\n`);
72
- }
73
90
  function errorResponse(id, code, message, data) {
74
91
  const error = { code, message };
75
92
  if (data !== undefined)
@@ -83,7 +100,7 @@ function handleMethod(server, method, params) {
83
100
  if (method === "initialize") {
84
101
  return {
85
102
  protocolVersion: PROTOCOL_VERSION,
86
- serverInfo: { name: SERVER_NAME, version: SERVER_VERSION },
103
+ serverInfo: { name: SERVER_NAME, version: serverVersion() },
87
104
  capabilities: { tools: {} }
88
105
  };
89
106
  }
@@ -126,9 +143,27 @@ class MethodNotFoundError extends Error {
126
143
  */
127
144
  export function runMcpStdio(options) {
128
145
  const { root, stdin, stdout, stderr } = options;
129
- if (options.readiness && !options.autoOpen) {
146
+ // L0 trace: the ambient per-attempt trace installed by bin.ts. Undefined in
147
+ // unit tests and normal CLI verbs → every call is a no-op. It only ever
148
+ // writes to stderr, never to the JSON-RPC stdout stream.
149
+ const trace = getActiveMcpTrace();
150
+ // Only the FIRST tool response is the calibration milestone; the rest are
151
+ // still measured but tagged distinctly.
152
+ let firstToolCallTraced = false;
153
+ if (options.readiness && !options.autoOpen && !options.identityRequest) {
130
154
  throw new Error("structured readiness requires successful auto-open");
131
155
  }
156
+ if (options.autoOpen && options.identityRequest) {
157
+ throw new Error("autoOpen and identityRequest are mutually exclusive");
158
+ }
159
+ // L2: the live signing identity, set only once identity is really bound. The
160
+ // server reads it on each h2a_send via `getSendContext`, so a signer that only
161
+ // becomes available after asynchronous activation is picked up with no rebuild.
162
+ let liveSendContext = options.sendContext;
163
+ // Forward reference to the identity controller; assigned below when the
164
+ // deferred identity path is used. The server guards signed/mutating tools by
165
+ // reading its state on every call.
166
+ let identityController;
132
167
  const heartbeatIntervalMs = options.heartbeatIntervalMs ?? envInt("H2A_HEARTBEAT_INTERVAL_MS");
133
168
  const notifyIntervalMs = options.notifyIntervalMs ??
134
169
  envInt("H2A_NOTIFY_INTERVAL_MS") ??
@@ -137,11 +172,25 @@ export function runMcpStdio(options) {
137
172
  // The stdio transport carries live agent sessions; enable autoHeartbeat so
138
173
  // the presence file stays fresh while this mcp-serve process is alive.
139
174
  let delegation;
175
+ // L1: forward reference — the notification sink is installed on the server
176
+ // below, but the bounded emitter needs `server.frameBudget`/`payloadStore`,
177
+ // which only exist after creation. Ticks are unref'd and interval-driven, so
178
+ // the real emitter is always assigned before the first tick fires.
179
+ let emitNotification = () => ({ accepted: false });
140
180
  const server = createMcpServer({
141
181
  root,
142
182
  workspaceRoot: options.workspaceRoot ?? process.cwd(),
143
183
  ...(options.runExecutor ? { runExecutor: options.runExecutor } : {}),
144
184
  ...(options.sendContext ? { sendContext: options.sendContext } : {}),
185
+ // L2: with a deferred identity, the store must not write on boot so
186
+ // initialize/tools-list/status answer on a read-only or not-yet-created root.
187
+ ...(options.identityRequest ? { storeInitialize: false } : {}),
188
+ // L2: the live signer is read on each call so post-activation availability
189
+ // (or its absence after a failure) is always reflected honestly.
190
+ getSendContext: () => liveSendContext,
191
+ get identity() {
192
+ return identityController;
193
+ },
145
194
  delegationContext: () => delegation,
146
195
  sessions: {
147
196
  autoHeartbeat: true,
@@ -150,19 +199,112 @@ export function runMcpStdio(options) {
150
199
  },
151
200
  notifications: {
152
201
  ...(notifyIntervalMs !== undefined ? { intervalMs: notifyIntervalMs } : {}),
153
- sink: (notification) => {
154
- stdout.write(`${JSON.stringify(notification)}\n`);
155
- }
202
+ sink: (notification) => emitNotification(notification, {
203
+ method: notification.method,
204
+ topic: typeof notification.params?.topic === "string"
205
+ ? notification.params.topic
206
+ : undefined
207
+ })
156
208
  }
157
209
  });
210
+ // ---------------------------------------------------------------------------
211
+ // L1: the SINGLE bounded output writer. Every frame — success, error, parse
212
+ // error, initialize, tools/list, a preformatted Track result, AND every pushed
213
+ // notification — is bounded to the frame budget here; there is NO direct write
214
+ // to `stdout` anywhere else. Writes are ordered and drain-aware (backpressure),
215
+ // and the queue is byte-bounded so a slow reader cannot grow memory unbounded.
216
+ // ---------------------------------------------------------------------------
217
+ const frameBudget = server.frameBudget;
218
+ const recover = (intactJson) => {
219
+ try {
220
+ return server.payloadStore.persistOutput(Buffer.from(intactJson, "utf8"));
221
+ }
222
+ catch {
223
+ return undefined;
224
+ }
225
+ };
226
+ const MAX_QUEUE_BYTES = 8 * 1024 * 1024;
227
+ let writeChain = Promise.resolve();
228
+ let queuedBytes = 0;
229
+ function enqueueLine(line) {
230
+ const bytes = Buffer.byteLength(line, "utf8");
231
+ queuedBytes += bytes;
232
+ writeChain = writeChain
233
+ .then(() => new Promise((resolve, reject) => {
234
+ const flushed = stdout.write(line, (err) => {
235
+ if (err)
236
+ reject(err);
237
+ });
238
+ if (flushed)
239
+ resolve();
240
+ else
241
+ stdout.once("drain", resolve);
242
+ }))
243
+ .then(() => {
244
+ queuedBytes -= bytes;
245
+ }, (err) => {
246
+ queuedBytes -= bytes;
247
+ throw err;
248
+ });
249
+ writeChain.catch((err) => {
250
+ const message = err instanceof Error ? err.message : String(err);
251
+ try {
252
+ stderr.write(`h2a mcp-serve: stdout write error: ${message}\n`);
253
+ }
254
+ catch {
255
+ /* stderr is diagnostic-only; a broken diagnostic sink is not fatal */
256
+ }
257
+ });
258
+ }
259
+ /** Bound and enqueue a JSON-RPC response; returns the frame ACTUALLY emitted. */
260
+ function emitResponse(id, responseObject) {
261
+ const frame = boundResponseFrame(id, responseObject, frameBudget, recover);
262
+ enqueueLine(frame.line);
263
+ return frame;
264
+ }
265
+ emitNotification = (notification, meta) => {
266
+ if (queuedBytes > MAX_QUEUE_BYTES) {
267
+ // Queue bound: drop rather than grow memory unbounded. A response is never
268
+ // dropped; a notification is retried by the dispatcher (snapshot held).
269
+ try {
270
+ stderr.write(`h2a mcp-serve: notification queue full; dropped ${meta.method}\n`);
271
+ }
272
+ catch {
273
+ /* diagnostic only */
274
+ }
275
+ return { accepted: false };
276
+ }
277
+ const outcome = boundNotificationFrame(notification, meta, frameBudget, recover);
278
+ if (!outcome) {
279
+ try {
280
+ stderr.write(`h2a mcp-serve: dropped unserializable notification ${meta.method}\n`);
281
+ }
282
+ catch {
283
+ /* diagnostic only */
284
+ }
285
+ return { accepted: false };
286
+ }
287
+ enqueueLine(outcome.frame.line);
288
+ return { accepted: outcome.accepted };
289
+ };
158
290
  // DEC-052: start the periodic diff scan so subscribed sessions receive
159
291
  // pushed presence/inbox/negotiation notifications.
160
292
  server.notifications.start();
161
293
  let didShutdown = false;
162
- function shutdown() {
294
+ function shutdown(reason = "transport_closed") {
163
295
  if (didShutdown)
164
296
  return;
165
297
  didShutdown = true;
298
+ try {
299
+ // L2: the transport is gone — cancel any in-flight identity resolution so
300
+ // no NEW transaction starts and no late activation/ACK can follow. A
301
+ // transaction already entered under a lock finishes cooperatively; a
302
+ // blocked worker is logged, never SIGKILL'd (it may hold a fence).
303
+ identityController?.cancel(reason);
304
+ }
305
+ catch {
306
+ /* best effort */
307
+ }
166
308
  try {
167
309
  server.notifications.stop();
168
310
  server.sessions.closeAll("closed");
@@ -175,62 +317,84 @@ export function runMcpStdio(options) {
175
317
  // WP-F: the auto-opened session id, so the line loop can mark MCP activity on
176
318
  // it (presence-honesty — proof the host→server channel is carrying traffic).
177
319
  let autoOpenedSessionId;
178
- // DEC-105 (EVO-6): auto-open a presence session at boot when requested, so
179
- // the host joins the bus at startup. Historically this is best-effort; a
180
- // structured readiness challenge upgrades failure to fatal because no ACK
181
- // may be published for an unreachable sidecar.
182
- if (options.autoOpen) {
183
- try {
184
- const opened = server.sessions.open({
185
- instance: options.autoOpen.instance,
186
- ...(options.autoOpen.host !== undefined ? { host: options.autoOpen.host } : {}),
187
- ...(options.autoOpen.workspace !== undefined
188
- ? { workspace: options.autoOpen.workspace }
189
- : {}),
190
- ...(options.autoOpen.name !== undefined ? { name: options.autoOpen.name } : {}),
191
- version: agentVersion(options.autoOpen.host),
192
- // Auto-capture our owning local terminal (native session or inherited
193
- // tmux pane) so loop scheduling has an explicit wake target.
194
- ...((() => {
195
- const lc = detectLocalLaunchContext(process.env, undefined, `h2a mcp-serve --host ${options.autoOpen.host ?? ""}`.trim());
196
- return lc ? { launchContext: lc } : {};
197
- })()),
198
- interests: {
199
- scopes: [...(options.autoOpen.scopes ?? ["scope:default"])],
200
- negotiations: []
201
- }
202
- });
203
- autoOpenedSessionId = opened.sessionId;
204
- // Spec 2026-07-25-h2a-lane-addressing §D1b: follow the host-native title
205
- // for the life of the session, so a rename converges into presence within
206
- // one heartbeat instead of staying stale until the host reconnects.
207
- // Absent when the operator passed an explicit `--name`.
208
- if (options.autoOpen.refreshDisplayName) {
209
- server.sessions.setDisplayNameResolver(opened.sessionId, options.autoOpen.refreshDisplayName);
210
- }
211
- const delegatorTmuxSession = currentTmuxSessionForSidecar();
212
- if (delegatorTmuxSession && options.autoOpen.delegationEligible === true) {
213
- delegation = {
214
- origin: "mcp:h2a_run",
215
- delegatorInstance: options.autoOpen.instance,
216
- delegatorTmuxSession,
217
- };
218
- recordTmuxOwner(delegatorTmuxSession, options.autoOpen.instance);
219
- }
220
- stderr.write(`h2a mcp-serve: auto-opened session for ${options.autoOpen.instance}\n`);
221
- // Reap the false-live presence left by a previous connection of THIS
222
- // agent that the host dropped without signalling (process lingered,
223
- // blind heartbeat kept presence "live"). Best-effort, same-instance only.
224
- try {
225
- const reaped = reapDeadInstancePresence(root, options.autoOpen.instance, opened.sessionId);
226
- if (reaped.length > 0) {
227
- stderr.write(`h2a mcp-serve: reaped ${reaped.length} stale presence file(s) for ${options.autoOpen.instance}\n`);
228
- }
320
+ function openAutoOpenSession(cfg) {
321
+ const opened = server.sessions.open({
322
+ instance: cfg.instance,
323
+ ...(cfg.host !== undefined ? { host: cfg.host } : {}),
324
+ ...(cfg.workspace !== undefined ? { workspace: cfg.workspace } : {}),
325
+ ...(cfg.name !== undefined ? { name: cfg.name } : {}),
326
+ version: agentVersion(cfg.host),
327
+ // Auto-capture our owning local terminal (native session or inherited
328
+ // tmux pane) so loop scheduling has an explicit wake target.
329
+ ...((() => {
330
+ const lc = detectLocalLaunchContext(process.env, undefined, `h2a mcp-serve --host ${cfg.host ?? ""}`.trim());
331
+ return lc ? { launchContext: lc } : {};
332
+ })()),
333
+ interests: {
334
+ scopes: [...(cfg.scopes ?? ["scope:default"])],
335
+ negotiations: []
229
336
  }
230
- catch {
231
- // best-effort
337
+ });
338
+ trace?.phase("session_open");
339
+ // Spec 2026-07-25-h2a-lane-addressing §D1b: follow the host-native title
340
+ // for the life of the session so a rename converges into presence within
341
+ // one heartbeat. Absent when the operator passed an explicit `--name`.
342
+ if (cfg.refreshDisplayName) {
343
+ server.sessions.setDisplayNameResolver(opened.sessionId, cfg.refreshDisplayName);
344
+ }
345
+ const delegatorTmuxSession = currentTmuxSessionForSidecar();
346
+ if (delegatorTmuxSession && cfg.delegationEligible === true) {
347
+ delegation = {
348
+ origin: "mcp:h2a_run",
349
+ delegatorInstance: cfg.instance,
350
+ delegatorTmuxSession
351
+ };
352
+ recordTmuxOwner(delegatorTmuxSession, cfg.instance);
353
+ }
354
+ stderr.write(`h2a mcp-serve: auto-opened session for ${cfg.instance}\n`);
355
+ // Reap the false-live presence left by a previous connection of THIS agent
356
+ // that the host dropped without signalling. Best-effort, same-instance only.
357
+ try {
358
+ const reaped = reapDeadInstancePresence(root, cfg.instance, opened.sessionId);
359
+ if (reaped.length > 0) {
360
+ stderr.write(`h2a mcp-serve: reaped ${reaped.length} stale presence file(s) for ${cfg.instance}\n`);
232
361
  }
233
362
  }
363
+ catch {
364
+ // best-effort
365
+ }
366
+ return opened.sessionId;
367
+ }
368
+ // EVO-1 wake (bug #3): wake the idle host when a new inbox envelope arrives.
369
+ function armInboxWake(cfg, wakeCfg) {
370
+ const wakeInstance = cfg.instance;
371
+ const wakeStore = createLocalStore({ root });
372
+ const wake = createInboxWakeHandler({
373
+ instance: wakeInstance,
374
+ readInbox: () => wakeStore.readInbox(wakeInstance),
375
+ privateKeyPem: wakeCfg.privateKeyPem,
376
+ driver: wakeCfg.driver,
377
+ ...(cfg.host !== undefined ? { host: cfg.host } : {}),
378
+ // Self-wake targets THIS process's OWN tmux pane (inherited $TMUX_PANE).
379
+ resolveLaunchContext: () => detectTmuxLaunchContext(process.env, undefined, `h2a mcp-serve --host ${cfg.host ?? ""}`.trim()),
380
+ ...(wakeCfg.nativeSessionId !== undefined
381
+ ? { resolveNativeSessionId: () => wakeCfg.nativeSessionId }
382
+ : {}),
383
+ log: (line) => stderr.write(`h2a mcp-serve: ${line}\n`)
384
+ });
385
+ server.notifications.setOnInboxArrival((instance) => {
386
+ if (instance === wakeInstance)
387
+ void wake();
388
+ });
389
+ stderr.write(`h2a mcp-serve: inbox-wake armed for ${wakeInstance}\n`);
390
+ }
391
+ // Eager auto-open: identity was resolved synchronously before the transport
392
+ // (the historical path, kept for existing consumers/tests). Best-effort unless
393
+ // a structured readiness challenge upgrades failure to fatal.
394
+ if (options.autoOpen) {
395
+ try {
396
+ autoOpenedSessionId = openAutoOpenSession(options.autoOpen);
397
+ }
234
398
  catch (err) {
235
399
  const message = err instanceof Error ? err.message : String(err);
236
400
  stderr.write(`h2a mcp-serve: auto-open failed: ${message}\n`);
@@ -239,32 +403,99 @@ export function runMcpStdio(options) {
239
403
  throw new Error(`structured auto-open failed: ${message}`);
240
404
  }
241
405
  }
242
- // EVO-1 wake (bug #3): wake the idle host when a new inbox envelope arrives.
243
- if (options.wake) {
244
- const wakeInstance = options.autoOpen.instance;
245
- const wakeStore = createLocalStore({ root });
246
- const wake = createInboxWakeHandler({
247
- instance: wakeInstance,
248
- readInbox: () => wakeStore.readInbox(wakeInstance),
249
- privateKeyPem: options.wake.privateKeyPem,
250
- driver: options.wake.driver,
251
- ...(options.autoOpen.host !== undefined ? { host: options.autoOpen.host } : {}),
252
- // Self-wake targets THIS process's OWN tmux pane (inherited $TMUX_PANE),
253
- // NOT latestLaunchContext(instance) — with concurrent sessions sharing one
254
- // perennial id (durable bug #1), an instance lookup could inject keystrokes
255
- // into a DIFFERENT agent's terminal. Native inbox delivery belongs to PR-1.
256
- resolveLaunchContext: () => detectTmuxLaunchContext(process.env, undefined, `h2a mcp-serve --host ${options.autoOpen?.host ?? ""}`.trim()),
257
- ...(options.wake.nativeSessionId !== undefined
258
- ? { resolveNativeSessionId: () => options.wake?.nativeSessionId }
259
- : {}),
260
- log: (line) => stderr.write(`h2a mcp-serve: ${line}\n`)
261
- });
262
- server.notifications.setOnInboxArrival((instance) => {
263
- if (instance === wakeInstance)
264
- void wake();
265
- });
266
- stderr.write(`h2a mcp-serve: inbox-wake armed for ${wakeInstance}\n`);
267
- }
406
+ if (options.wake)
407
+ armInboxWake(options.autoOpen, options.wake);
408
+ }
409
+ // L2 deferred identity: the transport is already live (initialize / tools/list
410
+ // / h2a_identity_status answer immediately). The child worker resolves identity
411
+ // OFF this event loop; only once it is really bound do we open the presence
412
+ // session, arm the live signer and wake, and publish the correlated readiness
413
+ // ACK. No provisional key, no early availability ACK.
414
+ if (options.identityRequest) {
415
+ const activation = options.identityActivation;
416
+ const activate = (identity) => {
417
+ if (!activation) {
418
+ return { ok: false, cause: "identity_worker_failed", message: "no activation wiring" };
419
+ }
420
+ // An explicit --instance override resolves with no key path: presence
421
+ // opens but no signer is available (h2a_send stays refused, honestly).
422
+ let privateKeyPem;
423
+ if (identity.privateKeyPath) {
424
+ try {
425
+ privateKeyPem = readFileSync(identity.privateKeyPath, "utf8");
426
+ }
427
+ catch (err) {
428
+ return {
429
+ ok: false,
430
+ cause: "identity_storage_failed",
431
+ message: `cannot read identity key: ${err instanceof Error ? err.message : String(err)}`
432
+ };
433
+ }
434
+ }
435
+ const cfg = activation.buildAutoOpen(identity);
436
+ let sessionId;
437
+ try {
438
+ sessionId = openAutoOpenSession(cfg);
439
+ }
440
+ catch (err) {
441
+ return {
442
+ ok: false,
443
+ cause: "session_open_failed",
444
+ message: err instanceof Error ? err.message : String(err)
445
+ };
446
+ }
447
+ autoOpenedSessionId = sessionId;
448
+ // F1: if any later activation step fails, roll ALL partial work back so a
449
+ // failed identity never leaves a live presence session, a live signer, an
450
+ // armed self-wake, or (worse) a published readiness ACK behind it.
451
+ const rollback = () => {
452
+ try {
453
+ server.sessions.close(sessionId, "closed");
454
+ }
455
+ catch {
456
+ /* best effort */
457
+ }
458
+ // Disarm the inbox-wake (it signs self-wake lines) — a no-op handler.
459
+ try {
460
+ server.notifications.setOnInboxArrival(() => { });
461
+ }
462
+ catch {
463
+ /* best effort */
464
+ }
465
+ liveSendContext = undefined;
466
+ autoOpenedSessionId = undefined;
467
+ };
468
+ if (privateKeyPem !== undefined) {
469
+ const wakeCfg = activation.buildWake?.(privateKeyPem, cfg.instance, cfg.host);
470
+ if (wakeCfg)
471
+ armInboxWake(cfg, wakeCfg);
472
+ // Only NOW is a trusted local signer available for h2a_send.
473
+ liveSendContext = { instance: cfg.instance, privateKeyPem };
474
+ }
475
+ if (options.readiness) {
476
+ try {
477
+ publishReadinessAck(options.readiness, sessionId);
478
+ trace?.phase("readiness_ack");
479
+ }
480
+ catch (err) {
481
+ // The ACK is the LAST step; a failure here would otherwise leave the
482
+ // session/signer/wake live under a `failed` state — roll them back.
483
+ rollback();
484
+ return {
485
+ ok: false,
486
+ cause: "readiness_ack_failed",
487
+ message: err instanceof Error ? err.message : String(err)
488
+ };
489
+ }
490
+ }
491
+ return { ok: true, sessionId, signer: liveSendContext };
492
+ };
493
+ identityController = createIdentityController({
494
+ request: options.identityRequest,
495
+ activate,
496
+ log: (line) => stderr.write(`h2a mcp-serve: ${line}\n`)
497
+ });
498
+ identityController.start();
268
499
  }
269
500
  // Publish only after every synchronous boot step above has completed and
270
501
  // immediately before constructing the stdio loop. Auto-upgrade/re-exec runs
@@ -272,6 +503,7 @@ export function runMcpStdio(options) {
272
503
  if (options.readiness && autoOpenedSessionId) {
273
504
  try {
274
505
  publishReadinessAck(options.readiness, autoOpenedSessionId);
506
+ trace?.phase("readiness_ack");
275
507
  }
276
508
  catch (err) {
277
509
  const message = err instanceof Error ? err.message : String(err);
@@ -288,14 +520,15 @@ export function runMcpStdio(options) {
288
520
  // sessions so presence is marked `closed` immediately rather than lingering
289
521
  // as false-live until expiry. Idempotent with the rl `close` path below.
290
522
  const onAbort = () => {
291
- shutdown();
523
+ shutdown("signal");
292
524
  try {
293
525
  rl.close();
294
526
  }
295
527
  catch {
296
528
  // ignore
297
529
  }
298
- resolve();
530
+ // Flush the bounded writer before resolving so no queued frame is lost.
531
+ void writeChain.finally(() => resolve());
299
532
  };
300
533
  if (options.signal) {
301
534
  if (options.signal.aborted) {
@@ -318,7 +551,7 @@ export function runMcpStdio(options) {
318
551
  }
319
552
  catch (err) {
320
553
  const message = err instanceof Error ? err.message : String(err);
321
- writeResponse(stdout, errorResponse(null, -32700, "Parse error", message));
554
+ emitResponse(null, errorResponse(null, -32700, "Parse error", message));
322
555
  return;
323
556
  }
324
557
  // JSON-RPC 2.0: a message with NO `id` member is a NOTIFICATION — the
@@ -330,16 +563,61 @@ export function runMcpStdio(options) {
330
563
  // at startup. Gate every stdout write on the message actually being a
331
564
  // request (DEC-115).
332
565
  const isNotification = !("id" in request);
566
+ // L1: reject an incoming id we cannot correlate within budget (a huge
567
+ // string, an object, an array) BEFORE building any response — so every
568
+ // response id below is a finite number, null, or a ≤128-byte string.
569
+ if (!isNotification && !isAcceptableRequestId(request.id)) {
570
+ emitResponse(null, buildInvalidRequestId());
571
+ return;
572
+ }
333
573
  if (request.jsonrpc !== "2.0" || typeof request.method !== "string") {
334
574
  if (!isNotification) {
335
- writeResponse(stdout, errorResponse(request.id ?? null, -32600, "Invalid Request"));
575
+ emitResponse((request.id ?? null), errorResponse(request.id ?? null, -32600, "Invalid Request"));
336
576
  }
337
577
  return;
338
578
  }
339
579
  try {
580
+ const requestId = typeof request.id === "string" || typeof request.id === "number"
581
+ ? request.id
582
+ : undefined;
583
+ if (trace && request.method === "initialize") {
584
+ trace.phase("initialize_recv", {
585
+ method: "initialize",
586
+ ...(requestId !== undefined ? { requestId } : {})
587
+ });
588
+ }
340
589
  const result = handleMethod(server, request.method, request.params);
341
590
  if (!isNotification) {
342
- writeResponse(stdout, successResponse(request.id ?? null, result));
591
+ // L1: the SINGLE bounded writer serializes ONCE, bounds the exact UTF-8
592
+ // bytes to the frame budget (an oversize result becomes a bounded -32010
593
+ // with a recovery ref — never a raw >B write), and returns the frame
594
+ // ACTUALLY emitted so the L0 calibration measures the true wire size.
595
+ const emitted = emitResponse((request.id ?? null), successResponse(request.id ?? null, result));
596
+ // L0 calibration semantics: the PAYLOAD bytes (no trailing newline),
597
+ // as before the single-writer refactor. The budget itself counts the
598
+ // newline; the trace stays payload-exact so the L0 witness is unchanged.
599
+ const bytes = Buffer.byteLength(emitted.json, "utf8");
600
+ if (trace) {
601
+ const rid = requestId !== undefined ? { requestId } : {};
602
+ if (request.method === "initialize") {
603
+ trace.phase("initialize_sent", { bytes, ...rid });
604
+ }
605
+ else if (request.method === "tools/list") {
606
+ trace.phase("tools_list_sent", { bytes, ...rid });
607
+ }
608
+ else if (request.method === "tools/call") {
609
+ const rawName = request.params && typeof request.params === "object"
610
+ ? request.params.name
611
+ : undefined;
612
+ const toolField = typeof rawName === "string" ? { tool: rawName } : {};
613
+ trace.phase(firstToolCallTraced ? "tool_sent" : "tool_first_sent", {
614
+ bytes,
615
+ ...rid,
616
+ ...toolField
617
+ });
618
+ firstToolCallTraced = true;
619
+ }
620
+ }
343
621
  }
344
622
  }
345
623
  catch (err) {
@@ -353,17 +631,19 @@ export function runMcpStdio(options) {
353
631
  return;
354
632
  }
355
633
  if (err instanceof MethodNotFoundError) {
356
- writeResponse(stdout, errorResponse(request.id ?? null, -32601, `Method not found: ${err.method}`));
634
+ emitResponse((request.id ?? null), errorResponse(request.id ?? null, -32601, `Method not found: ${err.method}`));
357
635
  return;
358
636
  }
359
637
  const message = err instanceof Error ? err.message : String(err);
360
638
  stderr.write(`h2a mcp-serve: internal error: ${message}\n`);
361
- writeResponse(stdout, errorResponse(request.id ?? null, -32603, `Internal error: ${message}`));
639
+ emitResponse((request.id ?? null), errorResponse(request.id ?? null, -32603, `Internal error: ${message}`));
362
640
  }
363
641
  });
364
642
  rl.on("close", () => {
365
643
  shutdown();
366
- resolve();
644
+ // Flush the bounded writer before resolving so the last frame is on the
645
+ // wire before the process (bin.ts awaits this promise) can exit.
646
+ void writeChain.finally(() => resolve());
367
647
  });
368
648
  rl.on("error", (err) => {
369
649
  shutdown();