@agent-deck/cli 1.9.0 → 1.10.1

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 (56) hide show
  1. package/dist/backend-runtime.d.ts +13 -0
  2. package/dist/backend-runtime.d.ts.map +1 -1
  3. package/dist/backend-runtime.js +20 -0
  4. package/dist/backend-runtime.js.map +1 -1
  5. package/dist/cli-integration-harness.d.ts +93 -0
  6. package/dist/cli-integration-harness.d.ts.map +1 -0
  7. package/dist/cli-integration-harness.js +309 -0
  8. package/dist/cli-integration-harness.js.map +1 -0
  9. package/dist/daemon-logs.d.ts +7 -0
  10. package/dist/daemon-logs.d.ts.map +1 -1
  11. package/dist/daemon-logs.js +55 -0
  12. package/dist/daemon-logs.js.map +1 -1
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +3 -2
  15. package/dist/index.js.map +1 -1
  16. package/dist/mcp-bridge.d.ts +182 -0
  17. package/dist/mcp-bridge.d.ts.map +1 -0
  18. package/dist/mcp-bridge.js +856 -0
  19. package/dist/mcp-bridge.js.map +1 -0
  20. package/dist/mcp-launcher.d.ts +9 -0
  21. package/dist/mcp-launcher.d.ts.map +1 -1
  22. package/dist/mcp-launcher.js +47 -0
  23. package/dist/mcp-launcher.js.map +1 -1
  24. package/dist/menubar.d.ts +13 -0
  25. package/dist/menubar.d.ts.map +1 -1
  26. package/dist/menubar.js +32 -1
  27. package/dist/menubar.js.map +1 -1
  28. package/dist/node-runtime.d.ts +14 -2
  29. package/dist/node-runtime.d.ts.map +1 -1
  30. package/dist/node-runtime.js +37 -18
  31. package/dist/node-runtime.js.map +1 -1
  32. package/dist/ports.d.ts +38 -0
  33. package/dist/ports.d.ts.map +1 -1
  34. package/dist/ports.js +63 -0
  35. package/dist/ports.js.map +1 -1
  36. package/dist/setup.js +1 -1
  37. package/dist/setup.js.map +1 -1
  38. package/dist/shutdown-reason.d.ts +105 -0
  39. package/dist/shutdown-reason.d.ts.map +1 -0
  40. package/dist/shutdown-reason.js +247 -0
  41. package/dist/shutdown-reason.js.map +1 -0
  42. package/dist/start.d.ts.map +1 -1
  43. package/dist/start.js +361 -37
  44. package/dist/start.js.map +1 -1
  45. package/dist/status.d.ts.map +1 -1
  46. package/dist/status.js +30 -0
  47. package/dist/status.js.map +1 -1
  48. package/dist/stop.d.ts +14 -1
  49. package/dist/stop.d.ts.map +1 -1
  50. package/dist/stop.js +72 -1
  51. package/dist/stop.js.map +1 -1
  52. package/dist/store.d.ts +9 -0
  53. package/dist/store.d.ts.map +1 -1
  54. package/dist/store.js +41 -0
  55. package/dist/store.js.map +1 -1
  56. package/package.json +3 -3
@@ -0,0 +1,856 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.McpStdioHttpBridge = void 0;
4
+ exports.isSessionInvalidResponse = isSessionInvalidResponse;
5
+ exports.readDeckIdFromToolResult = readDeckIdFromToolResult;
6
+ exports.parseSseMessages = parseSseMessages;
7
+ /**
8
+ * First-party stdio ↔ streamable-HTTP bridge (NOT-101).
9
+ *
10
+ * The MCP server keeps transport sessions in memory, so every restart — upgrade,
11
+ * crash recovery, `agent-deck stop && agent-deck start` — invalidates them. The
12
+ * spec's answer is that the server replies 404 to an unknown `Mcp-Session-Id` and
13
+ * the client re-initializes; `supergateway`, the bridge we used to shell out to,
14
+ * never implemented that half and stayed wedged until someone killed it by hand.
15
+ *
16
+ * This bridge owns the recovery: it caches the client's `initialize` handshake and
17
+ * replays it against the server when a session goes missing, then retries the
18
+ * request that failed. The stdio client upstream never sees the gap.
19
+ */
20
+ const shared_1 = require("@agent-deck/shared");
21
+ const node_readline_1 = require("node:readline");
22
+ const SESSION_HEADER = 'mcp-session-id';
23
+ const MCP_ACCEPT = 'application/json, text/event-stream';
24
+ const DEFAULT_DRAIN_TIMEOUT_MS = 2_000;
25
+ /**
26
+ * Pre-NOT-101 servers answered an unknown session with 400 + this JSON-RPC message
27
+ * instead of 404. Treat it as session-invalid so a new bridge recovers against an
28
+ * older backend that a user has not upgraded yet.
29
+ */
30
+ const LEGACY_SESSION_INVALID_MESSAGE = 'no valid session id provided';
31
+ /**
32
+ * Tools that move this session to another deck. A session override does not
33
+ * survive a restart, so the deck a replayed handshake lands on is whatever the
34
+ * launch headers say — which is why we track what the client bound to and refuse
35
+ * to replay a request across a deck change.
36
+ */
37
+ const DECK_REBINDING_TOOLS = new Set(['bind_workspace', 'switch_bound_deck']);
38
+ /** Read-only tool that reports the deck a session actually acts on. */
39
+ const SESSION_BINDING_TOOL = 'get_session_binding';
40
+ /**
41
+ * Requests whose answer depends on which deck the session acts on. While a
42
+ * recovery has moved the session off the deck the client bound, these are the
43
+ * ones that must not go out unannounced.
44
+ */
45
+ const DECK_SCOPED_METHODS = new Set(['tools/call', 'resources/read']);
46
+ function isSessionInvalidResponse(status, body) {
47
+ if (status === 404) {
48
+ return true;
49
+ }
50
+ return status === 400 && body.toLowerCase().includes(LEGACY_SESSION_INVALID_MESSAGE);
51
+ }
52
+ function isInitializeRequest(message) {
53
+ return message.method === 'initialize';
54
+ }
55
+ function isInitializedNotification(message) {
56
+ return message.method === 'notifications/initialized';
57
+ }
58
+ /** A message with an `id` and a `method` expects a response; everything else does not. */
59
+ function isRequest(message) {
60
+ return typeof message.method === 'string' && message.id !== undefined && message.id !== null;
61
+ }
62
+ /**
63
+ * A call that chooses or reports the session's deck. These are how a client gets
64
+ * out of a deck mismatch, so they are never the calls we hold back.
65
+ */
66
+ function isBindingCall(message) {
67
+ const tool = readToolCallName(message);
68
+ return tool !== undefined && (DECK_REBINDING_TOOLS.has(tool) || tool === SESSION_BINDING_TOOL);
69
+ }
70
+ /** A call that moves the session to another deck, as opposed to just reporting it. */
71
+ function isDeckRebindingCall(message) {
72
+ const tool = readToolCallName(message);
73
+ return tool !== undefined && DECK_REBINDING_TOOLS.has(tool);
74
+ }
75
+ function readToolCallName(message) {
76
+ if (message.method !== 'tools/call') {
77
+ return undefined;
78
+ }
79
+ const name = message.params?.name;
80
+ return typeof name === 'string' ? name : undefined;
81
+ }
82
+ /**
83
+ * Deck id out of an MCP tool result — the binding tools answer with one JSON
84
+ * document in a text content block.
85
+ */
86
+ function readDeckIdFromToolResult(result) {
87
+ const payload = result;
88
+ if (!payload || payload.isError || !Array.isArray(payload.content)) {
89
+ return undefined;
90
+ }
91
+ for (const item of payload.content) {
92
+ if (item?.type !== 'text' || typeof item.text !== 'string') {
93
+ continue;
94
+ }
95
+ try {
96
+ const parsed = JSON.parse(item.text);
97
+ const deckId = parsed.effective_deck_id ?? parsed.deck_id;
98
+ if (typeof deckId === 'string' && deckId) {
99
+ return deckId;
100
+ }
101
+ }
102
+ catch {
103
+ // Prose, not a binding payload.
104
+ }
105
+ }
106
+ return undefined;
107
+ }
108
+ /** Header maps arrive with whatever casing the launcher wrote. */
109
+ function readHeader(headers, name) {
110
+ const wanted = name.toLowerCase();
111
+ for (const [key, value] of Object.entries(headers)) {
112
+ if (key.toLowerCase() === wanted) {
113
+ return value;
114
+ }
115
+ }
116
+ return undefined;
117
+ }
118
+ /** Pull JSON-RPC payloads out of an SSE body (`data:` lines, blank-line delimited). */
119
+ function parseSseMessages(chunk) {
120
+ const messages = [];
121
+ for (const line of chunk.split(/\r?\n/)) {
122
+ if (!line.startsWith('data:')) {
123
+ continue;
124
+ }
125
+ const payload = line.slice('data:'.length).trim();
126
+ if (!payload) {
127
+ continue;
128
+ }
129
+ try {
130
+ messages.push(JSON.parse(payload));
131
+ }
132
+ catch {
133
+ // A partial frame — the caller re-feeds the remainder with the next chunk.
134
+ }
135
+ }
136
+ return messages;
137
+ }
138
+ class McpStdioHttpBridge {
139
+ options;
140
+ fetchImpl;
141
+ log;
142
+ sessionId;
143
+ /**
144
+ * Bumped every time the session id changes — including to `undefined`. A reply
145
+ * is only about the session it was sent on, and after a recovery the id alone
146
+ * cannot say that (the replacement may reuse a value we once held).
147
+ */
148
+ sessionGeneration = 0;
149
+ /** MCP endpoint as it stands now — re-read from the assignment on recovery. */
150
+ url;
151
+ /** Launch headers as they stand now — re-read from the assignment on recovery. */
152
+ launchHeaders;
153
+ /** The deck the client is working against: launch deck, or whatever it bound to. */
154
+ boundDeckId;
155
+ /**
156
+ * In-flight `bind_workspace` / `switch_bound_deck` calls: request id → the session
157
+ * generation the call went out on. A binding result only describes the session that
158
+ * answered it, so one that arrives after a restart must not be read as the deck the
159
+ * replacement session is on.
160
+ */
161
+ pendingDeckRebinds = new Map();
162
+ /**
163
+ * The deck the *client* asked for, if it ever did. Only a client that bound a deck
164
+ * itself can be surprised by a reconnect landing somewhere else — a client that
165
+ * simply took the folder assignment gets whatever that assignment says now.
166
+ */
167
+ clientChosenDeckId;
168
+ /**
169
+ * Set when a recovery moved the session off the deck the client chose. Held until
170
+ * it binds again: reporting the gap on the one request that happened to be in
171
+ * flight is not enough, because the call the client sends next would go to the
172
+ * new deck with nothing to show for it.
173
+ */
174
+ deckAwaitingRebind;
175
+ /**
176
+ * A session lost to a restart that the server still counts as stranded. Held
177
+ * across failed recovery attempts so the handshake that finally lands can name
178
+ * it — dropping it would leave that client unresolved on the server forever.
179
+ */
180
+ unresolvedSessionId;
181
+ bindingProbeSeq = 0;
182
+ /** The client's own handshake, replayed verbatim when a session disappears. */
183
+ cachedInitialize;
184
+ cachedInitialized;
185
+ streamAbort;
186
+ closed = false;
187
+ recovering;
188
+ /** The in-flight `initialize` exchange; later messages wait for it, not for each other. */
189
+ handshake;
190
+ /** Every dispatched client message, so stdin closing can drain instead of cutting them off. */
191
+ inFlight = new Set();
192
+ /** How many times we re-initialized after a restart — otherwise invisible, so the tests read it. */
193
+ recoveryCount = 0;
194
+ constructor(options) {
195
+ this.options = options;
196
+ this.url = options.url;
197
+ this.launchHeaders = { ...options.headers };
198
+ this.boundDeckId = readHeader(this.launchHeaders, shared_1.AGENT_DECK_DECK_ID_HEADER);
199
+ this.fetchImpl = options.fetchImpl ?? fetch;
200
+ this.log = options.log ?? ((message) => process.stderr.write(`${message}\n`));
201
+ }
202
+ getSessionId() {
203
+ return this.sessionId;
204
+ }
205
+ getRecoveryCount() {
206
+ return this.recoveryCount;
207
+ }
208
+ /** The deck this bridge believes its session acts on — diagnostics and tests. */
209
+ getBoundDeckId() {
210
+ return this.boundDeckId;
211
+ }
212
+ /** Resolves when stdin ends (the host closed the server). */
213
+ async run() {
214
+ const reader = (0, node_readline_1.createInterface)({ input: this.options.stdin });
215
+ try {
216
+ for await (const line of reader) {
217
+ const trimmed = line.trim();
218
+ if (!trimmed) {
219
+ continue;
220
+ }
221
+ let message;
222
+ try {
223
+ message = JSON.parse(trimmed);
224
+ }
225
+ catch {
226
+ this.log('[agent-deck] bridge: dropping non-JSON line from client');
227
+ continue;
228
+ }
229
+ // Deliberately not awaited: a slow tool call must not hold back the
230
+ // cancellation, ping, or unrelated request the client sends next.
231
+ this.dispatch(message);
232
+ }
233
+ }
234
+ finally {
235
+ reader.close();
236
+ await this.drain();
237
+ this.close();
238
+ }
239
+ }
240
+ /**
241
+ * Start one client message and keep it tracked. Nothing here may reject: an
242
+ * escaping error would tear down `run()` and, with it, the whole bridge process
243
+ * — the exact failure mode a restart is supposed to be recoverable from.
244
+ */
245
+ dispatch(message) {
246
+ const task = this.forwardFromClient(message).catch((error) => {
247
+ this.failRequest(message, `bridge error: ${describeError(error)}`);
248
+ });
249
+ this.inFlight.add(task);
250
+ void task.then(() => this.inFlight.delete(task));
251
+ }
252
+ /** Give in-flight exchanges a bounded chance to finish once stdin is gone. */
253
+ async drain() {
254
+ const timeoutMs = this.options.drainTimeoutMs ?? DEFAULT_DRAIN_TIMEOUT_MS;
255
+ const deadline = Date.now() + timeoutMs;
256
+ while (this.inFlight.size > 0) {
257
+ const remaining = deadline - Date.now();
258
+ if (remaining <= 0) {
259
+ this.log(`[agent-deck] bridge: ${this.inFlight.size} request(s) still open at shutdown`);
260
+ return;
261
+ }
262
+ const timer = deadlineTimer(remaining);
263
+ try {
264
+ await Promise.race([Promise.all([...this.inFlight]), timer.expired]);
265
+ }
266
+ finally {
267
+ timer.cancel();
268
+ }
269
+ }
270
+ }
271
+ close() {
272
+ this.closed = true;
273
+ this.streamAbort?.abort();
274
+ this.streamAbort = undefined;
275
+ }
276
+ /**
277
+ * Hand a message to the client. `sentOnGeneration` is the session generation the
278
+ * response came back on — absent for anything the bridge writes itself.
279
+ */
280
+ writeToClient(message, sentOnGeneration) {
281
+ const outgoing = this.noteDeckRebinding(message, sentOnGeneration) ?? message;
282
+ this.options.stdout.write(`${JSON.stringify(outgoing)}\n`);
283
+ }
284
+ /**
285
+ * Follow the client's own binding calls. `bind_workspace` moves the session to a
286
+ * deck the launch headers know nothing about, so this is the only place the
287
+ * bridge can learn which deck a later request expects to act on.
288
+ *
289
+ * Returns a replacement message when the binding answer belongs to a session a
290
+ * restart has since taken away: the deck it names is not where the bridge is now,
291
+ * and letting the success through would leave the client acting on the wrong deck
292
+ * believing it had bound.
293
+ */
294
+ noteDeckRebinding(message, sentOnGeneration) {
295
+ if (message.id === undefined || message.id === null) {
296
+ return undefined;
297
+ }
298
+ const id = String(message.id);
299
+ const sentAt = this.pendingDeckRebinds.get(id);
300
+ if (sentAt === undefined || sentAt !== sentOnGeneration) {
301
+ // Not a binding call of ours, or a reply superseded by a later attempt on
302
+ // the same id — the attempt that is still outstanding owns the answer.
303
+ return undefined;
304
+ }
305
+ this.pendingDeckRebinds.delete(id);
306
+ const deckId = readDeckIdFromToolResult(message.result);
307
+ if (!deckId) {
308
+ // An error or a payload naming no deck: nothing to record either way.
309
+ return undefined;
310
+ }
311
+ // Whichever session answered, the client has told us which deck it wants — so
312
+ // a later recovery landing elsewhere is a change it needs to hear about.
313
+ this.clientChosenDeckId = deckId;
314
+ if (sentOnGeneration === this.sessionGeneration) {
315
+ this.boundDeckId = deckId;
316
+ // The client has chosen a deck on the current session, so whatever the last
317
+ // recovery moved it away from is settled.
318
+ this.deckAwaitingRebind = undefined;
319
+ return undefined;
320
+ }
321
+ if (this.boundDeckId === deckId) {
322
+ // The replacement session happens to sit on the deck it asked for; the
323
+ // binding stands even though the session that granted it is gone.
324
+ this.deckAwaitingRebind = undefined;
325
+ return undefined;
326
+ }
327
+ // Latched, not merely reported: the client believes it is on `deckId`, so every
328
+ // deck-scoped call it sends next would land on the replacement's deck unnoticed.
329
+ this.deckAwaitingRebind = { chosen: deckId };
330
+ const reason = `${deckChangeNotice(this.boundDeckId, deckId)} — the binding was applied to a session ` +
331
+ `that no longer exists; ${rebindInstruction(deckId)} again`;
332
+ this.log(`[agent-deck] bridge: ${reason}`);
333
+ return {
334
+ jsonrpc: '2.0',
335
+ id: message.id,
336
+ error: { code: -32001, message: `agent-deck bridge: ${reason}` },
337
+ };
338
+ }
339
+ requestHeaders() {
340
+ const headers = {
341
+ ...this.launchHeaders,
342
+ 'Content-Type': 'application/json',
343
+ Accept: MCP_ACCEPT,
344
+ };
345
+ if (this.sessionId) {
346
+ headers[SESSION_HEADER] = this.sessionId;
347
+ }
348
+ return headers;
349
+ }
350
+ async post(message, extraHeaders) {
351
+ return this.fetchImpl(this.url, {
352
+ method: 'POST',
353
+ headers: { ...this.requestHeaders(), ...extraHeaders },
354
+ body: JSON.stringify(message),
355
+ });
356
+ }
357
+ async forwardFromClient(message) {
358
+ if (isInitializeRequest(message)) {
359
+ this.cachedInitialize = message;
360
+ // A fresh handshake supersedes any session we were holding — and with it the
361
+ // deck that session had landed on. The new one binds from the launch headers,
362
+ // and the client makes its own binding choices on top of that, so a refusal
363
+ // latched against the old session must not outlive it.
364
+ this.setSessionId(undefined);
365
+ this.boundDeckId = readHeader(this.launchHeaders, shared_1.AGENT_DECK_DECK_ID_HEADER);
366
+ this.clientChosenDeckId = undefined;
367
+ this.deckAwaitingRebind = undefined;
368
+ // Assigned before the first await so a message read on the very next line
369
+ // already sees the gate and waits for the session id.
370
+ const handshake = this.deliver(message, { allowRecovery: false });
371
+ this.handshake = handshake;
372
+ try {
373
+ await handshake;
374
+ }
375
+ finally {
376
+ if (this.handshake === handshake) {
377
+ this.handshake = undefined;
378
+ }
379
+ }
380
+ return;
381
+ }
382
+ if (isInitializedNotification(message)) {
383
+ this.cachedInitialized = message;
384
+ }
385
+ await this.awaitSession();
386
+ if (this.refuseUntilRebound(message)) {
387
+ return;
388
+ }
389
+ await this.deliver(message, { allowRecovery: true });
390
+ }
391
+ /**
392
+ * Refuse a deck-scoped call while the session sits on a deck the client never
393
+ * chose. The binding tools themselves go through — they are how it gets out.
394
+ */
395
+ refuseUntilRebound(message) {
396
+ if (this.deckAwaitingRebind === undefined) {
397
+ return false;
398
+ }
399
+ if (!message.method || !DECK_SCOPED_METHODS.has(message.method)) {
400
+ return false;
401
+ }
402
+ if (isBindingCall(message)) {
403
+ return false;
404
+ }
405
+ const { chosen } = this.deckAwaitingRebind;
406
+ this.failRequest(message, `${deckChangeNotice(this.boundDeckId, chosen)} — the call was not sent; ` +
407
+ `${rebindInstruction(chosen)} first`);
408
+ return true;
409
+ }
410
+ /**
411
+ * Hold a message only for the two exchanges that own the session id — the
412
+ * handshake and a restart recovery. Everything else goes out concurrently, so
413
+ * one long tool call cannot block the cancellation that would end it.
414
+ */
415
+ async awaitSession() {
416
+ // Waiting on one gate can admit the other (a recovery can start while the
417
+ // handshake is still running), so loop until neither is outstanding.
418
+ while (this.handshake || this.recovering) {
419
+ await settled(this.handshake);
420
+ await settled(this.recovering);
421
+ }
422
+ }
423
+ /** POST one client message, recovering once if the session went away. */
424
+ async deliver(message, { allowRecovery }) {
425
+ // The session this particular request went out on. Concurrent requests can
426
+ // come back stale one after another; without this we would re-initialize once
427
+ // per response and throw away the session the first recovery just won.
428
+ const sentWithSession = this.sessionId;
429
+ const sentWithGeneration = this.sessionGeneration;
430
+ // ...and the deck it was meant for. A replayed handshake re-binds from the
431
+ // launch headers, which can land on a different deck than the one the client
432
+ // bound this session to; a retry then applies the call to the wrong deck.
433
+ const sentWithDeck = this.boundDeckId;
434
+ // Recorded per attempt, not per request: a retry after recovery goes out on
435
+ // the new session, and its answer is the one that describes where we are.
436
+ if (isRequest(message) && isDeckRebindingCall(message)) {
437
+ this.pendingDeckRebinds.set(String(message.id), sentWithGeneration);
438
+ }
439
+ let response;
440
+ try {
441
+ response = await this.post(message);
442
+ }
443
+ catch (error) {
444
+ this.failRequest(message, `transport error: ${describeError(error)}`);
445
+ return;
446
+ }
447
+ // `undefined` means the status line arrived but the body never finished —
448
+ // a restart that lands between the two. Status alone still classifies a 404.
449
+ const bodyText = await readBodyText(response);
450
+ if (allowRecovery && isSessionInvalidResponse(response.status, bodyText ?? '')) {
451
+ this.log(`[agent-deck] bridge: MCP session ${sentWithSession ?? '(none)'} is no longer valid ` +
452
+ `(HTTP ${response.status}) — the server restarted. Re-initializing.`);
453
+ if (!(await this.ensureRecovered(sentWithSession))) {
454
+ this.failRequest(message, 'MCP server restarted and re-initialization failed');
455
+ return;
456
+ }
457
+ // Recovery replays the handshake itself — retrying it would send the new
458
+ // session a duplicate `initialized`.
459
+ if (isInitializedNotification(message)) {
460
+ return;
461
+ }
462
+ if (this.boundDeckId !== sentWithDeck && !isBindingCall(message)) {
463
+ // The session is healthy again, but on another deck. Replaying here could
464
+ // apply a mutation to a deck the client never chose, so hand the gap back
465
+ // instead: the client re-binds and decides whether to send this again.
466
+ // A binding call is exempt — it is the client doing exactly that.
467
+ this.failRequest(message, `${deckChangeNotice(this.boundDeckId, sentWithDeck)} — the request was not retried; ` +
468
+ `${rebindInstruction(sentWithDeck)} and send it again`);
469
+ return;
470
+ }
471
+ // The recovery may have latched a refusal that was not in force when this
472
+ // request went out — a binding answer from the lost session can land while
473
+ // the handshake is running, and it names a deck the replacement is not on.
474
+ if (this.refuseUntilRebound(message)) {
475
+ return;
476
+ }
477
+ await this.deliver(message, { allowRecovery: false });
478
+ return;
479
+ }
480
+ if (!response.ok) {
481
+ const detail = (bodyText ?? '').trim();
482
+ this.failRequest(message, `MCP server returned HTTP ${response.status}: ${detail}`);
483
+ return;
484
+ }
485
+ if (bodyText === undefined) {
486
+ // The server accepted the request before the connection died, so the call
487
+ // may well have run. Replaying it could double-apply a mutation, so report
488
+ // the gap to the client and let it decide; the bridge stays up and the next
489
+ // request re-initializes through the normal 404 path.
490
+ this.failRequest(message, 'connection to the MCP server was interrupted while reading the response ' +
491
+ '(the server may have restarted mid-call) — the request was not retried ' +
492
+ 'automatically because it may already have been applied');
493
+ return;
494
+ }
495
+ this.captureSessionId(response);
496
+ this.emitResponseBody(response, bodyText, sentWithGeneration);
497
+ if (isInitializeRequest(message)) {
498
+ this.startServerStream();
499
+ }
500
+ }
501
+ /** Every session change goes through here, so the generation cannot drift. */
502
+ setSessionId(sessionId) {
503
+ if (this.sessionId === sessionId) {
504
+ return;
505
+ }
506
+ this.sessionId = sessionId;
507
+ this.sessionGeneration += 1;
508
+ }
509
+ captureSessionId(response) {
510
+ const sessionId = response.headers.get(SESSION_HEADER);
511
+ if (sessionId) {
512
+ this.setSessionId(sessionId);
513
+ }
514
+ }
515
+ emitResponseBody(response, bodyText, sentOnGeneration) {
516
+ const messages = decodeJsonRpcMessages(response, bodyText);
517
+ if (!messages) {
518
+ this.log('[agent-deck] bridge: dropping non-JSON response from MCP server');
519
+ return;
520
+ }
521
+ for (const message of messages) {
522
+ this.writeToClient(message, sentOnGeneration);
523
+ }
524
+ }
525
+ /**
526
+ * Recover the session a request was sent on — once, no matter how many of its
527
+ * siblings come back stale. A second handshake for the same invalidation would
528
+ * discard a session that is already working and leave the first one orphaned on
529
+ * the server, where it shows up as another stranded client.
530
+ */
531
+ async ensureRecovered(sentWithSession) {
532
+ if (this.recovering) {
533
+ // Someone is already re-initializing; that handshake is this one's answer.
534
+ return this.recovering;
535
+ }
536
+ if (this.sessionId && this.sessionId !== sentWithSession) {
537
+ // A recovery finished while this request was in flight. Retry on its session.
538
+ return true;
539
+ }
540
+ return this.recover();
541
+ }
542
+ /**
543
+ * Replay the cached handshake against the restarted server. Its responses are
544
+ * swallowed — the client already completed its handshake and would reject a
545
+ * second `initialize` result for an id it no longer has outstanding.
546
+ */
547
+ async recover() {
548
+ this.recovering ??= this.runRecovery().finally(() => {
549
+ this.recovering = undefined;
550
+ });
551
+ return this.recovering;
552
+ }
553
+ async runRecovery() {
554
+ const initialize = this.cachedInitialize;
555
+ if (!initialize) {
556
+ this.log('[agent-deck] bridge: cannot re-initialize — no initialize request seen yet');
557
+ return false;
558
+ }
559
+ this.streamAbort?.abort();
560
+ this.streamAbort = undefined;
561
+ if (this.sessionId) {
562
+ // A session we still hold is the one we are about to lose. With none in
563
+ // hand we are retrying an earlier failed recovery, so keep naming the
564
+ // session that one never resolved.
565
+ this.unresolvedSessionId = this.sessionId;
566
+ }
567
+ const lostSessionId = this.unresolvedSessionId;
568
+ this.setSessionId(undefined);
569
+ // The folder assignment may have moved to another deck while we were up; the
570
+ // handshake has to go out with the binding that is current now.
571
+ await this.refreshLaunchTarget();
572
+ let response;
573
+ try {
574
+ // Naming the lost session lets the server mark it recovered instead of
575
+ // reporting this client as stranded forever (a wedged supergateway sends no
576
+ // such header and stays unresolved, which is the case operators need to see).
577
+ response = await this.post(initialize, lostSessionId ? { [shared_1.AGENT_DECK_RECOVERED_SESSION_HEADER]: lostSessionId } : undefined);
578
+ }
579
+ catch (error) {
580
+ this.log(`[agent-deck] bridge: re-initialize failed: ${describeError(error)}`);
581
+ return false;
582
+ }
583
+ if (!response.ok) {
584
+ this.log(`[agent-deck] bridge: re-initialize rejected with HTTP ${response.status}`);
585
+ return false;
586
+ }
587
+ this.captureSessionId(response);
588
+ if ((await readBodyText(response)) === undefined) {
589
+ // A handshake we could not read to the end is not a session we can trust —
590
+ // drop it so the next request takes the 404 path and recovers cleanly.
591
+ this.setSessionId(undefined);
592
+ this.log('[agent-deck] bridge: re-initialize response was cut off; will retry');
593
+ return false;
594
+ }
595
+ if (!this.sessionId) {
596
+ this.log('[agent-deck] bridge: re-initialize returned no session id');
597
+ return false;
598
+ }
599
+ if (this.cachedInitialized) {
600
+ try {
601
+ const ack = await this.post(this.cachedInitialized);
602
+ await readBodyText(ack);
603
+ }
604
+ catch (error) {
605
+ this.log(`[agent-deck] bridge: initialized notification failed: ${describeError(error)}`);
606
+ }
607
+ }
608
+ // The replacement exists, so the server has been told which session it stands
609
+ // in for and nothing is left unresolved for this client.
610
+ this.unresolvedSessionId = undefined;
611
+ this.recoveryCount += 1;
612
+ // Only a deck the client bound itself can be lost here. Following the folder
613
+ // assignment somewhere else is the reconnect working as intended, and latching
614
+ // on it would wedge every host that never calls `bind_workspace`.
615
+ this.boundDeckId = await this.resolveSessionDeck();
616
+ // Read the client's choice *after* the probe, never before it. A binding answer
617
+ // from the session we just lost can land during any of the awaits above, and it
618
+ // is a later statement of what the client wants than anything snapshotted
619
+ // earlier — reading a stale `undefined` here would clear the refusal that answer
620
+ // just latched and let the next mutation through on the wrong deck.
621
+ const chosen = this.clientChosenDeckId;
622
+ if (chosen === undefined || this.boundDeckId === chosen) {
623
+ // Either the client never chose, or a later restart put us back where it was.
624
+ this.deckAwaitingRebind = undefined;
625
+ }
626
+ else {
627
+ // Latched, not just reported: every deck-scoped call waits for the client to
628
+ // bind again, so none of them lands on this deck by accident.
629
+ this.deckAwaitingRebind = { chosen };
630
+ this.log(`[agent-deck] bridge: ${deckChangeNotice(this.boundDeckId, chosen)} — deck-scoped ` +
631
+ 'requests are refused until the client binds again.');
632
+ }
633
+ this.log(`[agent-deck] bridge: reconnected with MCP session ${this.sessionId}`);
634
+ this.startServerStream();
635
+ return true;
636
+ }
637
+ async refreshLaunchTarget() {
638
+ if (!this.options.resolveTarget) {
639
+ return;
640
+ }
641
+ try {
642
+ const target = await this.options.resolveTarget();
643
+ if (target?.headers) {
644
+ this.launchHeaders = { ...target.headers };
645
+ }
646
+ if (target?.url) {
647
+ // The assignment can name a different MCP endpoint than the one we
648
+ // launched against; replaying to the old one would reconnect nowhere.
649
+ this.url = target.url;
650
+ }
651
+ }
652
+ catch (error) {
653
+ this.log(`[agent-deck] bridge: could not re-read the folder assignment (${describeError(error)}); ` +
654
+ 'reconnecting with the values we launched with');
655
+ }
656
+ }
657
+ /**
658
+ * Ask the recovered session which deck it actually acts on. A session deck
659
+ * override is gone after a restart, so this is the answer to compare a pending
660
+ * request against — and the launch header is only the fallback for a server
661
+ * that does not expose the binding tool.
662
+ */
663
+ async resolveSessionDeck() {
664
+ const headerDeck = readHeader(this.launchHeaders, shared_1.AGENT_DECK_DECK_ID_HEADER);
665
+ this.bindingProbeSeq += 1;
666
+ // Namespaced so it can never collide with a client's own request id; the
667
+ // answer is read here and never forwarded upstream.
668
+ const id = `agent-deck-bridge/binding-${this.bindingProbeSeq}`;
669
+ try {
670
+ const response = await this.post({
671
+ jsonrpc: '2.0',
672
+ id,
673
+ method: 'tools/call',
674
+ params: { name: SESSION_BINDING_TOOL, arguments: {} },
675
+ });
676
+ const bodyText = await readBodyText(response);
677
+ if (!response.ok || bodyText === undefined) {
678
+ return headerDeck;
679
+ }
680
+ for (const message of decodeJsonRpcMessages(response, bodyText) ?? []) {
681
+ if (String(message.id) === id) {
682
+ return readDeckIdFromToolResult(message.result) ?? headerDeck;
683
+ }
684
+ }
685
+ }
686
+ catch (error) {
687
+ this.log(`[agent-deck] bridge: could not read the new session binding: ${describeError(error)}`);
688
+ }
689
+ return headerDeck;
690
+ }
691
+ failRequest(message, reason) {
692
+ this.log(`[agent-deck] bridge: ${reason}`);
693
+ if (message.id !== undefined && message.id !== null) {
694
+ // No result is coming, so this call tells us nothing about the deck.
695
+ this.pendingDeckRebinds.delete(String(message.id));
696
+ }
697
+ if (!isRequest(message)) {
698
+ // Notifications have no reply channel; the log line is all we can offer.
699
+ return;
700
+ }
701
+ this.writeToClient({
702
+ jsonrpc: '2.0',
703
+ id: message.id,
704
+ error: { code: -32001, message: `agent-deck bridge: ${reason}` },
705
+ });
706
+ }
707
+ /**
708
+ * Server→client stream (`GET /mcp`). Reconnects on drop; a 404 here is the same
709
+ * restart signal as on POST, so it recovers through the same path.
710
+ */
711
+ startServerStream() {
712
+ if (this.closed || !this.sessionId) {
713
+ return;
714
+ }
715
+ this.streamAbort?.abort();
716
+ const abort = new AbortController();
717
+ this.streamAbort = abort;
718
+ void this.consumeServerStream(abort);
719
+ }
720
+ async consumeServerStream(abort) {
721
+ const sessionId = this.sessionId;
722
+ // A response that arrives over this stream belongs to the session that opened
723
+ // it, the same way a POST reply belongs to the session it was sent on.
724
+ const generation = this.sessionGeneration;
725
+ try {
726
+ const response = await this.fetchImpl(this.url, {
727
+ method: 'GET',
728
+ headers: { ...this.launchHeaders, Accept: 'text/event-stream', [SESSION_HEADER]: sessionId },
729
+ signal: abort.signal,
730
+ });
731
+ if (isSessionInvalidResponse(response.status, await peekBody(response))) {
732
+ if (!abort.signal.aborted) {
733
+ // Same rule as on POST: only re-initialize if this stream's session is
734
+ // still the current one, otherwise a POST already recovered it.
735
+ await this.ensureRecovered(sessionId);
736
+ }
737
+ return;
738
+ }
739
+ if (!response.ok || !response.body) {
740
+ // 405 means this server has no server→client stream; stay POST-only.
741
+ return;
742
+ }
743
+ const decoder = new TextDecoder();
744
+ let buffer = '';
745
+ // Node's fetch body is async-iterable at runtime; the DOM lib types it as
746
+ // a ReadableStream only.
747
+ const stream = response.body;
748
+ for await (const chunk of stream) {
749
+ buffer += decoder.decode(chunk, { stream: true });
750
+ const lastBreak = buffer.lastIndexOf('\n');
751
+ if (lastBreak === -1) {
752
+ continue;
753
+ }
754
+ const complete = buffer.slice(0, lastBreak + 1);
755
+ buffer = buffer.slice(lastBreak + 1);
756
+ for (const message of parseSseMessages(complete)) {
757
+ this.writeToClient(message, generation);
758
+ }
759
+ }
760
+ }
761
+ catch {
762
+ // Abort or socket reset — the retry below decides whether to come back.
763
+ }
764
+ if (abort.signal.aborted || this.closed || this.sessionId !== sessionId) {
765
+ return;
766
+ }
767
+ await delay(this.options.streamRetryDelayMs ?? 1_000);
768
+ if (!abort.signal.aborted && !this.closed && this.sessionId === sessionId) {
769
+ this.startServerStream();
770
+ }
771
+ }
772
+ }
773
+ exports.McpStdioHttpBridge = McpStdioHttpBridge;
774
+ /**
775
+ * JSON-RPC messages out of a response body, whichever framing the server chose.
776
+ * `undefined` means the body was not JSON at all — the caller logs that; an empty
777
+ * array is a legitimately empty body (202 Accepted for a notification).
778
+ */
779
+ function decodeJsonRpcMessages(response, bodyText) {
780
+ if (!bodyText.trim()) {
781
+ return [];
782
+ }
783
+ if ((response.headers.get('content-type') ?? '').includes('text/event-stream')) {
784
+ return parseSseMessages(bodyText);
785
+ }
786
+ try {
787
+ const parsed = JSON.parse(bodyText);
788
+ return Array.isArray(parsed) ? parsed : [parsed];
789
+ }
790
+ catch {
791
+ return undefined;
792
+ }
793
+ }
794
+ /**
795
+ * Read a response body, distinguishing "empty" from "the connection died before
796
+ * the body finished". `fetch` resolves as soon as the headers land, so a server
797
+ * that restarts mid-response rejects here — and an unhandled rejection at this
798
+ * point used to take the whole bridge process down with it.
799
+ */
800
+ async function readBodyText(response) {
801
+ try {
802
+ return await response.text();
803
+ }
804
+ catch {
805
+ return undefined;
806
+ }
807
+ }
808
+ async function peekBody(response) {
809
+ if (response.ok) {
810
+ return '';
811
+ }
812
+ return (await readBodyText(response)) ?? '';
813
+ }
814
+ function delay(ms) {
815
+ return new Promise((resolve) => setTimeout(resolve, ms));
816
+ }
817
+ /** A timer we can cancel, so shutdown is not held open by its own deadline. */
818
+ function deadlineTimer(ms) {
819
+ let handle;
820
+ const expired = new Promise((resolve) => {
821
+ handle = setTimeout(resolve, ms);
822
+ });
823
+ return {
824
+ expired,
825
+ cancel: () => {
826
+ if (handle) {
827
+ clearTimeout(handle);
828
+ }
829
+ },
830
+ };
831
+ }
832
+ /** Await a gate without adopting its failure — the caller has its own error path. */
833
+ function settled(promise) {
834
+ return promise ? promise.then(noop, noop) : Promise.resolve();
835
+ }
836
+ function noop() {
837
+ // Intentionally empty.
838
+ }
839
+ /** One phrasing of "this session is not on the deck you bound", used on both paths. */
840
+ function deckChangeNotice(currentDeck, chosenDeck) {
841
+ return (`the MCP server restarted and the new session is bound to deck ${currentDeck ?? '(none)'}, ` +
842
+ `not ${chosenDeck ?? '(none)'}`);
843
+ }
844
+ /**
845
+ * The way out, naming the deck to bind back to. Without the id an agent tends to
846
+ * reach for the first deck in the sentence — the one it must not act on.
847
+ */
848
+ function rebindInstruction(chosenDeck) {
849
+ return chosenDeck
850
+ ? `re-bind with bind_workspace(deckId: "${chosenDeck}")`
851
+ : 're-bind with bind_workspace';
852
+ }
853
+ function describeError(error) {
854
+ return error instanceof Error ? error.message : String(error);
855
+ }
856
+ //# sourceMappingURL=mcp-bridge.js.map