@1presence/bridge 0.34.0 → 0.36.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/claude.js +83 -18
- package/dist/index.js +17 -0
- package/package.json +1 -1
package/dist/claude.js
CHANGED
|
@@ -6,6 +6,7 @@ exports.setDebug = setDebug;
|
|
|
6
6
|
exports.paint = paint;
|
|
7
7
|
exports.spawnClaude = spawnClaude;
|
|
8
8
|
exports.killAll = killAll;
|
|
9
|
+
exports.cancelConversation = cancelConversation;
|
|
9
10
|
const child_process_1 = require("child_process");
|
|
10
11
|
const fs_1 = require("fs");
|
|
11
12
|
const os_1 = require("os");
|
|
@@ -111,15 +112,32 @@ function renderHistoryMessage(msg) {
|
|
|
111
112
|
}
|
|
112
113
|
// ─── Active processes ─────────────────────────────────────────────────────────
|
|
113
114
|
const active = new Map();
|
|
114
|
-
//
|
|
115
|
-
//
|
|
116
|
-
//
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
|
|
120
|
-
//
|
|
121
|
-
//
|
|
122
|
-
|
|
115
|
+
// conversationId → pending retry timer. A retry is scheduled with a backoff
|
|
116
|
+
// delay, during which the conversation has NO entry in `active`. If a new user
|
|
117
|
+
// message arrives in that window it must cancel the stale retry (otherwise the
|
|
118
|
+
// retry would re-run the OLD turn's history and clobber the new one). The
|
|
119
|
+
// supersede block clears any pending timer here before spawning.
|
|
120
|
+
const pendingRetries = new Map();
|
|
121
|
+
// Automatic retries when the `claude` CLI exits non-zero BEFORE producing any
|
|
122
|
+
// real output. This covers the known Claude Code print-mode 400 regression that
|
|
123
|
+
// surfaces as "API Error: 400 due to tool use concurrency issues" (GitHub
|
|
124
|
+
// anthropics/claude-code#18131, still open) — it is non-deterministic enough
|
|
125
|
+
// that a fresh spawn often succeeds. We retry ONLY when the failed attempt
|
|
126
|
+
// produced no real assistant text and no tool calls, so a failure that lands
|
|
127
|
+
// after real work (where retrying could double-execute a side-effectful tool)
|
|
128
|
+
// is surfaced, never silently re-run.
|
|
129
|
+
//
|
|
130
|
+
// 2 retries = up to 3 attempts/turn. The first retry captures nearly all of the
|
|
131
|
+
// transient wins; further attempts buy little on transient failures but add
|
|
132
|
+
// latency and re-send the full (1M-context) history again on deterministic ones
|
|
133
|
+
// — see vault Bugs.md. Retries use escalating backoff (avoids a subscription
|
|
134
|
+
// rate-limit cascade from rapid re-spawns) and stop once total retry time
|
|
135
|
+
// exceeds the wall-clock cap, so a slow-failing attempt can't strand the user.
|
|
136
|
+
// All below the SSE boundary, so the user sees only a slightly longer
|
|
137
|
+
// "thinking" gap, never an intermediate error.
|
|
138
|
+
const MAX_TURN_RETRIES = 2;
|
|
139
|
+
const RETRY_BACKOFF_BASE_MS = 750; // delay = base * attempt# → 750ms, 1500ms
|
|
140
|
+
const RETRY_WALL_CLOCK_CAP_MS = 12_000; // stop retrying past this much elapsed
|
|
123
141
|
// Map a non-zero CLI exit + any captured "API Error:" line to a concise,
|
|
124
142
|
// user-facing Local Mode message. The raw upstream text stays in operator logs
|
|
125
143
|
// only — we never echo a wall of provider error JSON into the chat. Referring
|
|
@@ -135,7 +153,7 @@ const MAX_TURN_RETRIES = 1;
|
|
|
135
153
|
function describeCliFailure(code, apiErrorText) {
|
|
136
154
|
const t = apiErrorText.trim();
|
|
137
155
|
if (/API Error:\s*400/i.test(t) && /(tool use|concurren|parallel)/i.test(t)) {
|
|
138
|
-
return 'Local Mode hit a known Claude Code error (a print-mode bug that affects every current version). I retried
|
|
156
|
+
return 'Local Mode hit a known Claude Code error (a print-mode bug that affects every current version). I retried a few times automatically — sending the message again sometimes gets through. See https://github.com/anthropics/claude-code/issues/18131';
|
|
139
157
|
}
|
|
140
158
|
if (/^API Error:/i.test(t)) {
|
|
141
159
|
return `Local Mode error from Claude Code: ${t.replace(/^API Error:\s*/i, '').trim()}`;
|
|
@@ -144,8 +162,9 @@ function describeCliFailure(code, apiErrorText) {
|
|
|
144
162
|
}
|
|
145
163
|
// ─── Spawn ────────────────────────────────────────────────────────────────────
|
|
146
164
|
function spawnClaude(params) {
|
|
147
|
-
const { conversationId, presenceSessionId, text, uid, history, vaultFileOpen, clientCapabilities, syncedFolders, onEvent, onDone, onError } = params;
|
|
165
|
+
const { conversationId, presenceSessionId, text, uid, history, vaultFileOpen, clientCapabilities, syncedFolders, onEvent, onDone, onError, onNotice } = params;
|
|
148
166
|
const attemptIdx = params._attemptIdx ?? 0;
|
|
167
|
+
const firstAttemptAt = params._firstAttemptAt ?? Date.now();
|
|
149
168
|
const systemPromptPath = (0, path_1.join)((0, os_1.tmpdir)(), `agent-${uid}.md`);
|
|
150
169
|
const mcpConfigPath = (0, path_1.join)((0, os_1.tmpdir)(), `mcp-${uid}.json`);
|
|
151
170
|
if (verbose) {
|
|
@@ -201,6 +220,19 @@ function spawnClaude(params) {
|
|
|
201
220
|
existing.kill('SIGTERM');
|
|
202
221
|
active.delete(conversationId);
|
|
203
222
|
}
|
|
223
|
+
// Cancel any retry scheduled for this conversation that hasn't fired yet.
|
|
224
|
+
// Without this, a new user message arriving during a retry's backoff window
|
|
225
|
+
// would race the stale retry — which carries the OLD turn's history and would
|
|
226
|
+
// clobber the new turn. Skip when this call IS the retry firing (attemptIdx>0,
|
|
227
|
+
// the timer already deleted itself before invoking us).
|
|
228
|
+
if (attemptIdx === 0) {
|
|
229
|
+
const pending = pendingRetries.get(conversationId);
|
|
230
|
+
if (pending) {
|
|
231
|
+
clearTimeout(pending);
|
|
232
|
+
pendingRetries.delete(conversationId);
|
|
233
|
+
process.stderr.write(`[bridge] cancelled pending retry for ${conversationId} (superseded by new turn)\n`);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
204
236
|
// Note: ephemeral context (vault_file_open / client_capabilities / synced_folders)
|
|
205
237
|
// is injected into the last user message by the gateway BEFORE history is
|
|
206
238
|
// sent over the WS. The bridge no longer constructs `userMessageText` —
|
|
@@ -506,13 +538,23 @@ function spawnClaude(params) {
|
|
|
506
538
|
catch { /* ignore */ }
|
|
507
539
|
}
|
|
508
540
|
if (code !== 0 && code !== null) {
|
|
509
|
-
// Auto-retry
|
|
510
|
-
//
|
|
511
|
-
//
|
|
512
|
-
//
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
541
|
+
// Auto-retry when the CLI failed BEFORE producing any real output — the
|
|
542
|
+
// signature of the known print-mode 400 regression. A fresh spawn (new
|
|
543
|
+
// --session-id) often succeeds. We never retry once real text or a tool
|
|
544
|
+
// call landed, to avoid double-running a side-effectful tool. Retries use
|
|
545
|
+
// escalating backoff and stop past the wall-clock cap (see consts above).
|
|
546
|
+
const elapsed = Date.now() - firstAttemptAt;
|
|
547
|
+
if (attemptIdx < MAX_TURN_RETRIES && sawApiError && !producedRealOutput && elapsed < RETRY_WALL_CLOCK_CAP_MS) {
|
|
548
|
+
const delay = RETRY_BACKOFF_BASE_MS * (attemptIdx + 1);
|
|
549
|
+
const nextAttempt = attemptIdx + 2;
|
|
550
|
+
process.stderr.write(`[bridge] turn failed before output (${apiErrorText.replace(/\n+/g, ' ').slice(0, 120)}) — retrying (${nextAttempt} of ${MAX_TURN_RETRIES + 1}) in ${delay}ms\n`);
|
|
551
|
+
// Admin-only ephemeral thread notice — jargon is fine in Local Mode.
|
|
552
|
+
onNotice?.(`Claude Code print-mode 400 (tool-use concurrency, anthropics/claude-code#18131) — respawning, attempt ${nextAttempt}/${MAX_TURN_RETRIES + 1}…`);
|
|
553
|
+
const timer = setTimeout(() => {
|
|
554
|
+
pendingRetries.delete(conversationId);
|
|
555
|
+
spawnClaude({ ...params, _attemptIdx: attemptIdx + 1, _firstAttemptAt: firstAttemptAt });
|
|
556
|
+
}, delay);
|
|
557
|
+
pendingRetries.set(conversationId, timer);
|
|
516
558
|
return;
|
|
517
559
|
}
|
|
518
560
|
// Pass any partial token usage we observed before the failure so the
|
|
@@ -540,3 +582,26 @@ function killAll() {
|
|
|
540
582
|
}
|
|
541
583
|
active.clear();
|
|
542
584
|
}
|
|
585
|
+
/**
|
|
586
|
+
* Stop one in-flight turn (the Stop button, relayed by the gateway as a
|
|
587
|
+
* `cancel` frame). Kills the running Claude Code process for this conversation
|
|
588
|
+
* and cancels any scheduled retry, so no further stream events are produced.
|
|
589
|
+
* Mirrors the supersede path in spawnClaude. Returns true if something was
|
|
590
|
+
* actually stopped. Mechanical only — no product logic lives here.
|
|
591
|
+
*/
|
|
592
|
+
function cancelConversation(conversationId) {
|
|
593
|
+
let stopped = false;
|
|
594
|
+
const proc = active.get(conversationId);
|
|
595
|
+
if (proc) {
|
|
596
|
+
proc.kill('SIGTERM');
|
|
597
|
+
active.delete(conversationId);
|
|
598
|
+
stopped = true;
|
|
599
|
+
}
|
|
600
|
+
const pending = pendingRetries.get(conversationId);
|
|
601
|
+
if (pending) {
|
|
602
|
+
clearTimeout(pending);
|
|
603
|
+
pendingRetries.delete(conversationId);
|
|
604
|
+
stopped = true;
|
|
605
|
+
}
|
|
606
|
+
return stopped;
|
|
607
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -244,6 +244,14 @@ async function handleMessage(conversationId, text, sessionId, history, auth, vau
|
|
|
244
244
|
currentWs.send(JSON.stringify({ type: 'stream', conversationId, event }));
|
|
245
245
|
}
|
|
246
246
|
},
|
|
247
|
+
onNotice: (message) => {
|
|
248
|
+
// Ephemeral, non-persisted thread notice (admin-only Local Mode). Relayed
|
|
249
|
+
// by the gateway to the PWA SSE stream as a `notice` AgentEvent; it does
|
|
250
|
+
// NOT go through the turn accumulator, so it never lands in history.
|
|
251
|
+
if (currentWs?.readyState === ws_1.default.OPEN) {
|
|
252
|
+
currentWs.send(JSON.stringify({ type: 'notice', conversationId, message }));
|
|
253
|
+
}
|
|
254
|
+
},
|
|
247
255
|
onDone: (messageCount, costUsd, usage, model) => {
|
|
248
256
|
const parts = [];
|
|
249
257
|
if (usage)
|
|
@@ -378,6 +386,15 @@ function connect(auth, retryDelay = 1000) {
|
|
|
378
386
|
}
|
|
379
387
|
return;
|
|
380
388
|
}
|
|
389
|
+
// Stop button: the gateway relays a cancel when the user abandons the turn
|
|
390
|
+
// (PWA→gateway connection dropped). Kill the local Claude Code process for
|
|
391
|
+
// this conversation so it stops generating instead of running to the end.
|
|
392
|
+
if (msg.type === 'cancel' && msg.conversationId) {
|
|
393
|
+
const cancelled = (0, claude_1.cancelConversation)(msg.conversationId);
|
|
394
|
+
if (cancelled)
|
|
395
|
+
console.log(`[bridge] ✕ stopped conversation ${msg.conversationId}`);
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
381
398
|
if (msg.type !== 'message' || !msg.conversationId || !msg.text)
|
|
382
399
|
return;
|
|
383
400
|
const { conversationId, text, sessionId, history, vaultFileOpen, clientCapabilities, syncedFolders, agentSlug } = msg;
|