@minhspark/codex-mcp-bridge 1.12.1 → 1.12.2
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/CHANGELOG.md +17 -0
- package/README.md +24 -8
- package/package.json +2 -2
- package/scripts/check-claude-bridge.mjs +24 -20
- package/scripts/check.mjs +13 -7
- package/scripts/install-claude-desktop.mjs +1 -0
- package/scripts/install-native-relay.mjs +5 -20
- package/scripts/smoke.mjs +38 -35
- package/scripts/sync-version.mjs +1 -0
- package/src/app-server-client.mjs +271 -65
- package/src/claude-bridge.mjs +6 -6
- package/src/index.mjs +113 -81
- package/src/native-relay-companion.mjs +96 -57
- package/src/native-relay.mjs +168 -32
- package/src/peer-protocol.mjs +53 -5
- package/src/platform.mjs +10 -3
- package/src/thread-delivery.mjs +19 -17
- package/src/turn.mjs +28 -10
|
@@ -60,10 +60,11 @@ function httpBase(wsUrl) {
|
|
|
60
60
|
export function writerLockWarning(threadId) {
|
|
61
61
|
return [
|
|
62
62
|
"",
|
|
63
|
-
`NOTE:
|
|
64
|
-
"
|
|
65
|
-
|
|
66
|
-
"the
|
|
63
|
+
`NOTE: the app-server may still hold the writer lock on thread ${threadId}. The Codex app may`,
|
|
64
|
+
'report "open in another application" until the thread unloads.',
|
|
65
|
+
"Automatic release unsubscribes only this connection; the server's idle unload delay and other",
|
|
66
|
+
"subscribers can keep the writer lock alive. stop_codex_app_server stops the shared server and",
|
|
67
|
+
"interrupts every active turn, so use it only when all work on that server can stop.",
|
|
67
68
|
].join("\n");
|
|
68
69
|
}
|
|
69
70
|
|
|
@@ -98,7 +99,12 @@ export class CodexAppServerClient {
|
|
|
98
99
|
this.threadListeners = new Map();
|
|
99
100
|
this.disconnectListeners = new Set();
|
|
100
101
|
this.attachedThreads = new Set();
|
|
102
|
+
this.unsubscribedThreads = new Set();
|
|
101
103
|
this.threadCwds = new Map();
|
|
104
|
+
this.threadOperations = new Map();
|
|
105
|
+
this.attachingThreads = new Map();
|
|
106
|
+
this.activeTurns = new Map();
|
|
107
|
+
this.connectionEpoch = 0;
|
|
102
108
|
}
|
|
103
109
|
|
|
104
110
|
async isServerUp() {
|
|
@@ -139,11 +145,11 @@ export class CodexAppServerClient {
|
|
|
139
145
|
* delegations.
|
|
140
146
|
*/
|
|
141
147
|
async stopServer() {
|
|
148
|
+
this.close();
|
|
142
149
|
if (!(await this.isServerUp())) return { stopped: false, reason: "no app-server was listening" };
|
|
143
150
|
const port = appServerPort(this.url);
|
|
144
151
|
const pids = listeningPids(port);
|
|
145
152
|
if (!pids.length) return { stopped: false, reason: `nothing is listening on port ${port}` };
|
|
146
|
-
this.ws?.close();
|
|
147
153
|
for (const pid of pids) {
|
|
148
154
|
try {
|
|
149
155
|
if (process.platform === "win32") {
|
|
@@ -158,9 +164,6 @@ export class CodexAppServerClient {
|
|
|
158
164
|
this.log(`could not stop pid ${pid}: ${err.message}`);
|
|
159
165
|
}
|
|
160
166
|
}
|
|
161
|
-
this.ws = null;
|
|
162
|
-
this.attachedThreads.clear();
|
|
163
|
-
this.threadCwds.clear();
|
|
164
167
|
for (let attempt = 0; attempt < 20; attempt += 1) {
|
|
165
168
|
if (!(await this.isServerUp())) return { stopped: true, pids };
|
|
166
169
|
await delay(100);
|
|
@@ -175,33 +178,67 @@ export class CodexAppServerClient {
|
|
|
175
178
|
* first call instead of a failed tool call the user has to repeat by hand.
|
|
176
179
|
*/
|
|
177
180
|
async connect() {
|
|
178
|
-
if (this.ws && this.ws.readyState === WebSocket.OPEN) return;
|
|
179
181
|
if (this.connecting) return this.connecting;
|
|
182
|
+
if (this.ws && this.ws.readyState === WebSocket.OPEN) return;
|
|
183
|
+
if (this.ws) this.#disconnect(this.ws);
|
|
180
184
|
|
|
181
|
-
|
|
185
|
+
const epoch = this.connectionEpoch;
|
|
186
|
+
const connecting = (async () => {
|
|
182
187
|
let lastError = null;
|
|
183
188
|
for (let attempt = 1; attempt <= CONNECT_ATTEMPTS; attempt += 1) {
|
|
184
189
|
try {
|
|
185
|
-
|
|
190
|
+
this.#assertConnectionEpoch(epoch);
|
|
191
|
+
await this.#openConnection(epoch);
|
|
186
192
|
return;
|
|
187
193
|
} catch (err) {
|
|
188
194
|
lastError = err;
|
|
195
|
+
this.#assertConnectionEpoch(epoch);
|
|
189
196
|
this.log(`connect attempt ${attempt}/${CONNECT_ATTEMPTS} failed: ${err.message}`);
|
|
190
197
|
if (attempt < CONNECT_ATTEMPTS) await delay(CONNECT_RETRY_DELAY_MS);
|
|
191
198
|
}
|
|
192
199
|
}
|
|
193
200
|
throw lastError;
|
|
194
201
|
})();
|
|
202
|
+
this.connecting = connecting;
|
|
195
203
|
|
|
196
204
|
try {
|
|
197
|
-
await
|
|
205
|
+
await connecting;
|
|
198
206
|
} finally {
|
|
199
|
-
this.connecting = null;
|
|
207
|
+
if (this.connecting === connecting) this.connecting = null;
|
|
200
208
|
}
|
|
201
209
|
}
|
|
202
210
|
|
|
203
|
-
|
|
211
|
+
#assertConnectionEpoch(epoch) {
|
|
212
|
+
if (epoch !== this.connectionEpoch) {
|
|
213
|
+
throw new AppServerError("App-server connection attempt was cancelled", "CONNECTION_CLOSED");
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
#disconnect(ws, error = new AppServerError("Connection to Codex app-server closed", "CONNECTION_CLOSED")) {
|
|
218
|
+
if (!ws || this.ws !== ws) return;
|
|
219
|
+
this.log("app-server connection closed");
|
|
220
|
+
this.ws = null;
|
|
221
|
+
this.attachedThreads.clear();
|
|
222
|
+
this.unsubscribedThreads.clear();
|
|
223
|
+
this.threadCwds.clear();
|
|
224
|
+
this.attachingThreads.clear();
|
|
225
|
+
for (const active of this.activeTurns.values()) active.needsReconcile = true;
|
|
226
|
+
const pending = [...this.pending.values()];
|
|
227
|
+
this.pending.clear();
|
|
228
|
+
for (const entry of pending) entry.reject(error);
|
|
229
|
+
this.#notifyDisconnect();
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
close() {
|
|
233
|
+
this.connectionEpoch += 1;
|
|
234
|
+
const ws = this.ws;
|
|
235
|
+
this.#disconnect(ws);
|
|
236
|
+
ws?.close();
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async #openConnection(epoch) {
|
|
204
240
|
if (!(await this.isServerUp())) {
|
|
241
|
+
this.#assertConnectionEpoch(epoch);
|
|
205
242
|
if (!this.autoStart) {
|
|
206
243
|
throw new AppServerError(
|
|
207
244
|
`No Codex app-server reachable at ${this.url}. Start one with: codex app-server --listen ${this.url}`,
|
|
@@ -213,48 +250,55 @@ export class CodexAppServerClient {
|
|
|
213
250
|
}
|
|
214
251
|
}
|
|
215
252
|
|
|
253
|
+
this.#assertConnectionEpoch(epoch);
|
|
216
254
|
const ws = new WebSocket(this.url);
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
255
|
+
this.ws = ws;
|
|
256
|
+
try {
|
|
257
|
+
await new Promise((resolve, reject) => {
|
|
258
|
+
const timer = globalThis.setTimeout(
|
|
259
|
+
() => reject(new AppServerError(`Timed out connecting to ${this.url}`)),
|
|
260
|
+
15000,
|
|
261
|
+
);
|
|
262
|
+
ws.onopen = () => {
|
|
263
|
+
globalThis.clearTimeout(timer);
|
|
264
|
+
resolve();
|
|
265
|
+
};
|
|
266
|
+
ws.onerror = (event) => {
|
|
267
|
+
globalThis.clearTimeout(timer);
|
|
268
|
+
reject(new AppServerError(`WebSocket error against ${this.url}: ${event?.message ?? "unknown"}`));
|
|
269
|
+
};
|
|
270
|
+
ws.onclose = () => {
|
|
271
|
+
globalThis.clearTimeout(timer);
|
|
272
|
+
reject(new AppServerError(`Connection to ${this.url} closed during the handshake`));
|
|
273
|
+
};
|
|
274
|
+
});
|
|
275
|
+
} catch (err) {
|
|
276
|
+
this.#disconnect(ws, err);
|
|
277
|
+
ws.close();
|
|
278
|
+
throw err;
|
|
279
|
+
}
|
|
235
280
|
|
|
236
|
-
ws.onmessage = (event) =>
|
|
237
|
-
|
|
238
|
-
if (this.ws !== ws) return;
|
|
239
|
-
this.log("app-server connection closed");
|
|
240
|
-
this.ws = null;
|
|
241
|
-
this.attachedThreads.clear();
|
|
242
|
-
this.threadCwds.clear();
|
|
243
|
-
for (const [, entry] of this.pending) {
|
|
244
|
-
entry.reject(new AppServerError("Connection to Codex app-server closed"));
|
|
245
|
-
}
|
|
246
|
-
this.pending.clear();
|
|
247
|
-
this.#notifyDisconnect();
|
|
281
|
+
ws.onmessage = (event) => {
|
|
282
|
+
if (this.ws === ws) this.#handleMessage(event.data);
|
|
248
283
|
};
|
|
284
|
+
ws.onclose = () => this.#disconnect(ws);
|
|
249
285
|
ws.onerror = (event) => this.log(`websocket error: ${event?.message ?? "unknown"}`);
|
|
250
286
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
287
|
+
try {
|
|
288
|
+
this.#assertConnectionEpoch(epoch);
|
|
289
|
+
this.ws = ws;
|
|
290
|
+
const init = await this.request("initialize", {
|
|
291
|
+
clientInfo: this.clientInfo,
|
|
292
|
+
capabilities: { experimentalApi: true },
|
|
293
|
+
});
|
|
294
|
+
this.#assertConnectionEpoch(epoch);
|
|
295
|
+
this.#send({ jsonrpc: "2.0", method: "initialized", params: {} });
|
|
296
|
+
this.log(`connected to app-server (codexHome=${init?.codexHome ?? "?"})`);
|
|
297
|
+
} catch (err) {
|
|
298
|
+
this.#disconnect(ws, err);
|
|
299
|
+
ws.close();
|
|
300
|
+
throw err;
|
|
301
|
+
}
|
|
258
302
|
}
|
|
259
303
|
|
|
260
304
|
#send(payload) {
|
|
@@ -272,6 +316,7 @@ export class CodexAppServerClient {
|
|
|
272
316
|
this.log(`ignored non-JSON frame (${String(raw).slice(0, 80)})`);
|
|
273
317
|
return;
|
|
274
318
|
}
|
|
319
|
+
if (!msg || typeof msg !== "object" || Array.isArray(msg)) return;
|
|
275
320
|
|
|
276
321
|
if (msg.id !== undefined && msg.method === undefined) {
|
|
277
322
|
const entry = this.pending.get(msg.id);
|
|
@@ -296,6 +341,24 @@ export class CodexAppServerClient {
|
|
|
296
341
|
#dispatchNotification(msg) {
|
|
297
342
|
const threadId = msg.params?.threadId;
|
|
298
343
|
if (!threadId) return;
|
|
344
|
+
if (msg.method === "thread/closed") {
|
|
345
|
+
this.attachedThreads.delete(threadId);
|
|
346
|
+
this.unsubscribedThreads.delete(threadId);
|
|
347
|
+
this.threadCwds.delete(threadId);
|
|
348
|
+
this.activeTurns.delete(threadId);
|
|
349
|
+
}
|
|
350
|
+
const active = this.activeTurns.get(threadId);
|
|
351
|
+
if (active && msg.method === "turn/started" && msg.params?.turn?.id && !active.turnId) {
|
|
352
|
+
active.turnId = msg.params.turn.id;
|
|
353
|
+
}
|
|
354
|
+
if (active && msg.method === "turn/completed" && msg.params?.turn?.id &&
|
|
355
|
+
["completed", "interrupted", "failed"].includes(msg.params.turn.status)) {
|
|
356
|
+
const completedId = msg.params.turn.id;
|
|
357
|
+
if (!active.turnId) active.completedIds.add(completedId);
|
|
358
|
+
if (active.turnId === completedId || (!active.turnId && !active.awaitingStartResponse && !active.needsReconcile)) {
|
|
359
|
+
this.activeTurns.delete(threadId);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
299
362
|
const listeners = this.threadListeners.get(threadId);
|
|
300
363
|
if (!listeners) return;
|
|
301
364
|
for (const listener of [...listeners]) {
|
|
@@ -377,11 +440,20 @@ export class CodexAppServerClient {
|
|
|
377
440
|
}
|
|
378
441
|
|
|
379
442
|
async request(method, params, { timeoutMs = 60000 } = {}) {
|
|
443
|
+
const threadId = method === "turn/start" ? params?.threadId : null;
|
|
444
|
+
let active = null;
|
|
445
|
+
if (threadId) {
|
|
446
|
+
if (this.activeTurns.has(threadId)) {
|
|
447
|
+
throw new AppServerError(`Thread ${threadId} already has a running or unconfirmed turn`, "THREAD_BUSY");
|
|
448
|
+
}
|
|
449
|
+
active = { turnId: null, completedIds: new Set(), awaitingStartResponse: true };
|
|
450
|
+
this.activeTurns.set(threadId, active);
|
|
451
|
+
}
|
|
380
452
|
const id = ++this.nextId;
|
|
381
453
|
const promise = new Promise((resolve, reject) => {
|
|
382
454
|
const timer = globalThis.setTimeout(() => {
|
|
383
455
|
this.pending.delete(id);
|
|
384
|
-
reject(new AppServerError(`Request ${method} timed out after ${timeoutMs}ms
|
|
456
|
+
reject(new AppServerError(`Request ${method} timed out after ${timeoutMs}ms`, "REQUEST_TIMEOUT"));
|
|
385
457
|
}, timeoutMs);
|
|
386
458
|
this.pending.set(id, {
|
|
387
459
|
resolve: (value) => {
|
|
@@ -393,9 +465,30 @@ export class CodexAppServerClient {
|
|
|
393
465
|
reject(err);
|
|
394
466
|
},
|
|
395
467
|
});
|
|
468
|
+
try {
|
|
469
|
+
this.#send({ jsonrpc: "2.0", id, method, params: params ?? {} });
|
|
470
|
+
} catch (err) {
|
|
471
|
+
const entry = this.pending.get(id);
|
|
472
|
+
this.pending.delete(id);
|
|
473
|
+
entry.reject(err);
|
|
474
|
+
}
|
|
396
475
|
});
|
|
397
|
-
|
|
398
|
-
|
|
476
|
+
try {
|
|
477
|
+
const result = await promise;
|
|
478
|
+
if (active && this.activeTurns.get(threadId) === active) {
|
|
479
|
+
active.turnId = result?.turn?.id ?? null;
|
|
480
|
+
active.awaitingStartResponse = false;
|
|
481
|
+
if (active.completedIds.has(active.turnId) || ["completed", "interrupted", "failed"].includes(result?.turn?.status)) {
|
|
482
|
+
this.activeTurns.delete(threadId);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
return result;
|
|
486
|
+
} catch (err) {
|
|
487
|
+
if (active && !["REQUEST_TIMEOUT", "CONNECTION_CLOSED"].includes(err.code) && this.activeTurns.get(threadId) === active) {
|
|
488
|
+
this.activeTurns.delete(threadId);
|
|
489
|
+
}
|
|
490
|
+
throw err;
|
|
491
|
+
}
|
|
399
492
|
}
|
|
400
493
|
|
|
401
494
|
async call(method, params, opts) {
|
|
@@ -437,23 +530,136 @@ export class CodexAppServerClient {
|
|
|
437
530
|
|
|
438
531
|
async ensureThreadAttached(threadId, resumeParams = {}) {
|
|
439
532
|
await this.connect();
|
|
440
|
-
if (this.attachedThreads.has(threadId))
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
533
|
+
if (this.attachedThreads.has(threadId)) {
|
|
534
|
+
if (this.activeTurns.get(threadId)?.needsReconcile) await this.#reconcileThreadTurn(threadId);
|
|
535
|
+
return { resumed: false, thread: this.threadCwds.get(threadId) };
|
|
536
|
+
}
|
|
537
|
+
if (this.attachingThreads.has(threadId)) return this.attachingThreads.get(threadId);
|
|
538
|
+
const ws = this.ws;
|
|
539
|
+
const attaching = (async () => {
|
|
540
|
+
const result = await this.request("thread/resume", { ...resumeParams, threadId });
|
|
541
|
+
if (this.ws !== ws || ws.readyState !== WebSocket.OPEN) {
|
|
542
|
+
throw new AppServerError("Connection closed while attaching the thread", "CONNECTION_CLOSED");
|
|
543
|
+
}
|
|
544
|
+
this.markAttached(threadId, result?.thread);
|
|
545
|
+
await this.#reconcileThreadTurn(threadId, result?.thread);
|
|
546
|
+
return { resumed: true, thread: result?.thread };
|
|
547
|
+
})();
|
|
548
|
+
this.attachingThreads.set(threadId, attaching);
|
|
549
|
+
try {
|
|
550
|
+
return await attaching;
|
|
551
|
+
} finally {
|
|
552
|
+
if (this.attachingThreads.get(threadId) === attaching) this.attachingThreads.delete(threadId);
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
async #reconcileThreadTurn(threadId, thread = null) {
|
|
557
|
+
const inspect = (snapshot) => {
|
|
558
|
+
if (!snapshot || (snapshot.id && snapshot.id !== threadId)) return false;
|
|
559
|
+
const status = typeof snapshot.status === "string" ? snapshot.status : snapshot.status?.type;
|
|
560
|
+
const turns = Array.isArray(snapshot.turns) ? snapshot.turns : [];
|
|
561
|
+
const running = turns.find((turn) => turn?.status === "inProgress");
|
|
562
|
+
const active = this.activeTurns.get(threadId);
|
|
563
|
+
if (running || status === "active") {
|
|
564
|
+
this.activeTurns.set(threadId, {
|
|
565
|
+
turnId: running?.id ?? null,
|
|
566
|
+
completedIds: new Set(),
|
|
567
|
+
awaitingStartResponse: false,
|
|
568
|
+
needsReconcile: false,
|
|
569
|
+
});
|
|
570
|
+
return true;
|
|
571
|
+
}
|
|
572
|
+
if (status === "idle" || status === "notLoaded" || (active?.turnId && turns.some((turn) =>
|
|
573
|
+
turn?.id === active.turnId && ["completed", "interrupted", "failed"].includes(turn.status)))) {
|
|
574
|
+
this.activeTurns.delete(threadId);
|
|
575
|
+
return true;
|
|
576
|
+
}
|
|
577
|
+
return false;
|
|
578
|
+
};
|
|
579
|
+
if (inspect(thread) || !this.activeTurns.has(threadId)) return;
|
|
580
|
+
const active = this.activeTurns.get(threadId);
|
|
581
|
+
active.needsReconcile = true;
|
|
582
|
+
const ws = this.ws;
|
|
583
|
+
const result = await this.request("thread/read", { threadId, includeTurns: true });
|
|
584
|
+
if (this.ws !== ws || ws.readyState !== WebSocket.OPEN) {
|
|
585
|
+
throw new AppServerError("Connection closed while reconciling the thread", "CONNECTION_CLOSED");
|
|
586
|
+
}
|
|
587
|
+
if (!inspect(result?.thread)) {
|
|
588
|
+
throw new AppServerError(
|
|
589
|
+
`Cannot confirm whether thread ${threadId} still has its previous turn running; refusing to start another turn`,
|
|
590
|
+
"THREAD_STATE_UNCONFIRMED",
|
|
591
|
+
);
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
async withThread(threadId, action) {
|
|
596
|
+
const previous = this.threadOperations.get(threadId);
|
|
597
|
+
const operation = (previous ? previous.catch(() => {}) : Promise.resolve()).then(action);
|
|
598
|
+
this.threadOperations.set(threadId, operation);
|
|
599
|
+
try {
|
|
600
|
+
return await operation;
|
|
601
|
+
} finally {
|
|
602
|
+
if (this.threadOperations.get(threadId) === operation) this.threadOperations.delete(threadId);
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
async releaseThread(threadId, { timeoutMs = 1000 } = {}) {
|
|
607
|
+
const outcome = (status, unsubscribed, released, reason) => ({
|
|
608
|
+
threadId, status, unsubscribed, released, ...(reason ? { reason } : {}),
|
|
609
|
+
});
|
|
610
|
+
if (this.activeTurns.has(threadId) || this.threadListeners.get(threadId)?.size || this.attachingThreads.has(threadId)) {
|
|
611
|
+
return outcome("busy", false, false, "The thread has an active or unconfirmed turn or operation.");
|
|
612
|
+
}
|
|
613
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN || this.connecting) {
|
|
614
|
+
return outcome("disconnected", false, false, "No initialized connection is available to release this thread.");
|
|
615
|
+
}
|
|
616
|
+
let closed = false;
|
|
617
|
+
let disconnected = false;
|
|
618
|
+
let finishWait;
|
|
619
|
+
let timer;
|
|
620
|
+
const changed = new Promise((resolve) => { finishWait = resolve; });
|
|
621
|
+
const unsubscribe = this.subscribe(threadId, (msg) => {
|
|
622
|
+
if (msg.method !== "thread/closed") return;
|
|
623
|
+
closed = true;
|
|
624
|
+
finishWait();
|
|
625
|
+
});
|
|
626
|
+
const unsubscribeDisconnect = this.subscribeDisconnect(() => {
|
|
627
|
+
disconnected = true;
|
|
628
|
+
finishWait();
|
|
629
|
+
});
|
|
630
|
+
try {
|
|
631
|
+
const result = await this.request("thread/unsubscribe", { threadId }, { timeoutMs: 5000 });
|
|
632
|
+
if (!["unsubscribed", "notSubscribed", "notLoaded"].includes(result?.status)) {
|
|
633
|
+
return outcome("invalidResponse", false, false, "The server did not confirm thread unsubscription.");
|
|
634
|
+
}
|
|
635
|
+
this.attachedThreads.delete(threadId);
|
|
636
|
+
this.threadCwds.delete(threadId);
|
|
637
|
+
if (result.status === "notLoaded" || closed) {
|
|
638
|
+
this.unsubscribedThreads.delete(threadId);
|
|
639
|
+
return outcome(result.status, true, true);
|
|
640
|
+
}
|
|
641
|
+
if (!disconnected) this.unsubscribedThreads.add(threadId);
|
|
642
|
+
const waitMs = Number.isFinite(timeoutMs) ? Math.max(0, Math.min(timeoutMs, 5000)) : 1000;
|
|
643
|
+
timer = globalThis.setTimeout(finishWait, waitMs);
|
|
644
|
+
await changed;
|
|
645
|
+
return outcome(result.status, true, closed, closed ? null : disconnected
|
|
646
|
+
? "Subscription removed; the connection closed before thread unload was confirmed."
|
|
647
|
+
: "Subscription removed; thread unload is pending the server's idle delay or other subscribers.");
|
|
648
|
+
} catch (err) {
|
|
649
|
+
return outcome(err.code === -32601 ? "unsupported" : "failed", false, false, err.message);
|
|
650
|
+
} finally {
|
|
651
|
+
globalThis.clearTimeout(timer);
|
|
652
|
+
unsubscribe();
|
|
653
|
+
unsubscribeDisconnect();
|
|
654
|
+
}
|
|
445
655
|
}
|
|
446
656
|
|
|
447
|
-
/**
|
|
448
|
-
* The app-server takes the per-thread writer lock when it loads a thread and
|
|
449
|
-
* keeps it until it exits, so a thread this bridge has attached cannot be
|
|
450
|
-
* written to from anywhere else - the desktop app included.
|
|
451
|
-
*/
|
|
452
657
|
holdsThread(threadId) {
|
|
453
|
-
return this.attachedThreads.has(threadId);
|
|
658
|
+
return this.attachedThreads.has(threadId) || this.unsubscribedThreads.has(threadId);
|
|
454
659
|
}
|
|
455
660
|
|
|
456
661
|
markAttached(threadId, thread = null) {
|
|
662
|
+
this.unsubscribedThreads.delete(threadId);
|
|
457
663
|
this.attachedThreads.add(threadId);
|
|
458
664
|
if (thread?.cwd) this.threadCwds.set(threadId, thread);
|
|
459
665
|
}
|
package/src/claude-bridge.mjs
CHANGED
|
@@ -8,7 +8,7 @@ import { PLATFORM_LABEL } from "./platform.mjs";
|
|
|
8
8
|
import { PeerEndpoint, findClaudeSession, listClaudeSessions, readTranscript } from "./peer-protocol.mjs";
|
|
9
9
|
import { createThreadDelivery } from "./thread-delivery.mjs";
|
|
10
10
|
|
|
11
|
-
const VERSION = "1.12.
|
|
11
|
+
const VERSION = "1.12.2";
|
|
12
12
|
const FORWARD_MIN_INTERVAL_MS = 5000;
|
|
13
13
|
const FORWARD_MAX_PER_SESSION = 50;
|
|
14
14
|
|
|
@@ -131,7 +131,9 @@ server.registerTool(
|
|
|
131
131
|
title: "Send a message to a Claude session",
|
|
132
132
|
description:
|
|
133
133
|
"Deliver a message into a running Claude Code session. It appears in that session's chat exactly like " +
|
|
134
|
-
"a message from a teammate, and Claude can reply. Set waitSec to 0 to fire and forget."
|
|
134
|
+
"a message from a teammate, and Claude can reply. Set waitSec to 0 to fire and forget. " +
|
|
135
|
+
"A waited send is refused while earlier messages to that session still await replies; " +
|
|
136
|
+
"wait for those replies and read_claude_inbox before trying again.",
|
|
135
137
|
inputSchema: {
|
|
136
138
|
target: z.string().describe("Session name, pid or sessionId from list_claude_sessions"),
|
|
137
139
|
message: z.string().describe("The message text to deliver"),
|
|
@@ -156,14 +158,12 @@ server.registerTool(
|
|
|
156
158
|
const session = findClaudeSession(target);
|
|
157
159
|
if (!session) return textResult(`No live Claude session matches "${target}".`, true);
|
|
158
160
|
|
|
159
|
-
const
|
|
160
|
-
await peer.
|
|
161
|
+
const wait = waitSec ?? 180;
|
|
162
|
+
const { reply } = await peer.sendAndWait(session.socket, message, { timeoutMs: wait * 1000 });
|
|
161
163
|
const header = `delivered to ${session.name ?? session.pid} (pid ${session.pid}, session ${session.sessionId ?? "?"})`;
|
|
162
164
|
|
|
163
|
-
const wait = waitSec ?? 180;
|
|
164
165
|
if (wait === 0) return textResult(`${header}\nnot waiting for a reply.`);
|
|
165
166
|
|
|
166
|
-
const reply = await peer.waitForReply(session.socket, { timeoutMs: wait * 1000, since });
|
|
167
167
|
if (!reply) {
|
|
168
168
|
return textResult(
|
|
169
169
|
`${header}\n\nNo reply within ${wait}s. Claude may still be working - check again with read_claude_inbox.`,
|