@agent-deck/cli 1.8.2 → 1.10.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/dist/agent-harness.d.ts +5 -2
- package/dist/agent-harness.d.ts.map +1 -1
- package/dist/agent-harness.js +18 -7
- package/dist/agent-harness.js.map +1 -1
- package/dist/debug-mcp.d.ts +25 -0
- package/dist/debug-mcp.d.ts.map +1 -1
- package/dist/debug-mcp.js +93 -25
- package/dist/debug-mcp.js.map +1 -1
- package/dist/grant-issue.d.ts +0 -5
- package/dist/grant-issue.d.ts.map +1 -1
- package/dist/grant-issue.js +19 -70
- package/dist/grant-issue.js.map +1 -1
- package/dist/index.js +1 -1
- package/dist/mcp-bridge.d.ts +182 -0
- package/dist/mcp-bridge.d.ts.map +1 -0
- package/dist/mcp-bridge.js +856 -0
- package/dist/mcp-bridge.js.map +1 -0
- package/dist/mcp-config.d.ts.map +1 -1
- package/dist/mcp-config.js +8 -5
- package/dist/mcp-config.js.map +1 -1
- package/dist/mcp-launcher.d.ts +9 -0
- package/dist/mcp-launcher.d.ts.map +1 -1
- package/dist/mcp-launcher.js +47 -0
- package/dist/mcp-launcher.js.map +1 -1
- package/dist/ports.d.ts +38 -0
- package/dist/ports.d.ts.map +1 -1
- package/dist/ports.js +63 -0
- package/dist/ports.js.map +1 -1
- package/dist/setup.d.ts +6 -4
- package/dist/setup.d.ts.map +1 -1
- package/dist/setup.js +51 -26
- package/dist/setup.js.map +1 -1
- package/dist/start.d.ts +1 -0
- package/dist/start.d.ts.map +1 -1
- package/dist/start.js +6 -2
- package/dist/start.js.map +1 -1
- package/dist/status.d.ts.map +1 -1
- package/dist/status.js +3 -0
- package/dist/status.js.map +1 -1
- package/package.json +3 -3
- package/dist/coordinator.d.ts +0 -2
- package/dist/coordinator.d.ts.map +0 -1
- package/dist/coordinator.js +0 -153
- package/dist/coordinator.js.map +0 -1
|
@@ -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
|