@1presence/bridge 0.34.0 → 0.35.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 CHANGED
@@ -111,15 +111,32 @@ function renderHistoryMessage(msg) {
111
111
  }
112
112
  // ─── Active processes ─────────────────────────────────────────────────────────
113
113
  const active = new Map();
114
- // Maximum automatic retries when the `claude` CLI exits non-zero BEFORE
115
- // producing any real output. This covers the known Claude Code print-mode 400
116
- // regression that surfaces as "API Error: 400 due to tool use concurrency
117
- // issues" (GitHub anthropics/claude-code#18131, still open) it is
118
- // non-deterministic enough that a fresh spawn frequently succeeds. We retry
119
- // ONLY when the failed attempt produced no real assistant text and no tool
120
- // calls, so a failure that lands after real work (where retrying could
121
- // double-execute a side-effectful tool) is surfaced, never silently re-run.
122
- const MAX_TURN_RETRIES = 1;
114
+ // conversationId pending retry timer. A retry is scheduled with a backoff
115
+ // delay, during which the conversation has NO entry in `active`. If a new user
116
+ // message arrives in that window it must cancel the stale retry (otherwise the
117
+ // retry would re-run the OLD turn's history and clobber the new one). The
118
+ // supersede block clears any pending timer here before spawning.
119
+ const pendingRetries = new Map();
120
+ // Automatic retries when the `claude` CLI exits non-zero BEFORE producing any
121
+ // real output. This covers the known Claude Code print-mode 400 regression that
122
+ // surfaces as "API Error: 400 due to tool use concurrency issues" (GitHub
123
+ // anthropics/claude-code#18131, still open) — it is non-deterministic enough
124
+ // that a fresh spawn often succeeds. We retry ONLY when the failed attempt
125
+ // produced no real assistant text and no tool calls, so a failure that lands
126
+ // after real work (where retrying could double-execute a side-effectful tool)
127
+ // is surfaced, never silently re-run.
128
+ //
129
+ // 2 retries = up to 3 attempts/turn. The first retry captures nearly all of the
130
+ // transient wins; further attempts buy little on transient failures but add
131
+ // latency and re-send the full (1M-context) history again on deterministic ones
132
+ // — see vault Bugs.md. Retries use escalating backoff (avoids a subscription
133
+ // rate-limit cascade from rapid re-spawns) and stop once total retry time
134
+ // exceeds the wall-clock cap, so a slow-failing attempt can't strand the user.
135
+ // All below the SSE boundary, so the user sees only a slightly longer
136
+ // "thinking" gap, never an intermediate error.
137
+ const MAX_TURN_RETRIES = 2;
138
+ const RETRY_BACKOFF_BASE_MS = 750; // delay = base * attempt# → 750ms, 1500ms
139
+ const RETRY_WALL_CLOCK_CAP_MS = 12_000; // stop retrying past this much elapsed
123
140
  // Map a non-zero CLI exit + any captured "API Error:" line to a concise,
124
141
  // user-facing Local Mode message. The raw upstream text stays in operator logs
125
142
  // only — we never echo a wall of provider error JSON into the chat. Referring
@@ -135,7 +152,7 @@ const MAX_TURN_RETRIES = 1;
135
152
  function describeCliFailure(code, apiErrorText) {
136
153
  const t = apiErrorText.trim();
137
154
  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 once automatically — sending the message again sometimes gets through.';
155
+ 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
156
  }
140
157
  if (/^API Error:/i.test(t)) {
141
158
  return `Local Mode error from Claude Code: ${t.replace(/^API Error:\s*/i, '').trim()}`;
@@ -144,8 +161,9 @@ function describeCliFailure(code, apiErrorText) {
144
161
  }
145
162
  // ─── Spawn ────────────────────────────────────────────────────────────────────
146
163
  function spawnClaude(params) {
147
- const { conversationId, presenceSessionId, text, uid, history, vaultFileOpen, clientCapabilities, syncedFolders, onEvent, onDone, onError } = params;
164
+ const { conversationId, presenceSessionId, text, uid, history, vaultFileOpen, clientCapabilities, syncedFolders, onEvent, onDone, onError, onNotice } = params;
148
165
  const attemptIdx = params._attemptIdx ?? 0;
166
+ const firstAttemptAt = params._firstAttemptAt ?? Date.now();
149
167
  const systemPromptPath = (0, path_1.join)((0, os_1.tmpdir)(), `agent-${uid}.md`);
150
168
  const mcpConfigPath = (0, path_1.join)((0, os_1.tmpdir)(), `mcp-${uid}.json`);
151
169
  if (verbose) {
@@ -201,6 +219,19 @@ function spawnClaude(params) {
201
219
  existing.kill('SIGTERM');
202
220
  active.delete(conversationId);
203
221
  }
222
+ // Cancel any retry scheduled for this conversation that hasn't fired yet.
223
+ // Without this, a new user message arriving during a retry's backoff window
224
+ // would race the stale retry — which carries the OLD turn's history and would
225
+ // clobber the new turn. Skip when this call IS the retry firing (attemptIdx>0,
226
+ // the timer already deleted itself before invoking us).
227
+ if (attemptIdx === 0) {
228
+ const pending = pendingRetries.get(conversationId);
229
+ if (pending) {
230
+ clearTimeout(pending);
231
+ pendingRetries.delete(conversationId);
232
+ process.stderr.write(`[bridge] cancelled pending retry for ${conversationId} (superseded by new turn)\n`);
233
+ }
234
+ }
204
235
  // Note: ephemeral context (vault_file_open / client_capabilities / synced_folders)
205
236
  // is injected into the last user message by the gateway BEFORE history is
206
237
  // sent over the WS. The bridge no longer constructs `userMessageText` —
@@ -506,13 +537,23 @@ function spawnClaude(params) {
506
537
  catch { /* ignore */ }
507
538
  }
508
539
  if (code !== 0 && code !== null) {
509
- // Auto-retry once when the CLI failed BEFORE producing any real output —
510
- // the signature of the known print-mode 400 regression. A fresh spawn
511
- // (new --session-id) usually succeeds. We never retry once real text or a
512
- // tool call landed, to avoid double-running a side-effectful tool.
513
- if (attemptIdx < MAX_TURN_RETRIES && sawApiError && !producedRealOutput) {
514
- process.stderr.write(`[bridge] turn failed before output (${apiErrorText.replace(/\n+/g, ' ').slice(0, 120)}) — retrying once\n`);
515
- spawnClaude({ ...params, _attemptIdx: attemptIdx + 1 });
540
+ // Auto-retry when the CLI failed BEFORE producing any real output — the
541
+ // signature of the known print-mode 400 regression. A fresh spawn (new
542
+ // --session-id) often succeeds. We never retry once real text or a tool
543
+ // call landed, to avoid double-running a side-effectful tool. Retries use
544
+ // escalating backoff and stop past the wall-clock cap (see consts above).
545
+ const elapsed = Date.now() - firstAttemptAt;
546
+ if (attemptIdx < MAX_TURN_RETRIES && sawApiError && !producedRealOutput && elapsed < RETRY_WALL_CLOCK_CAP_MS) {
547
+ const delay = RETRY_BACKOFF_BASE_MS * (attemptIdx + 1);
548
+ const nextAttempt = attemptIdx + 2;
549
+ 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`);
550
+ // Admin-only ephemeral thread notice — jargon is fine in Local Mode.
551
+ onNotice?.(`Claude Code print-mode 400 (tool-use concurrency, anthropics/claude-code#18131) — respawning, attempt ${nextAttempt}/${MAX_TURN_RETRIES + 1}…`);
552
+ const timer = setTimeout(() => {
553
+ pendingRetries.delete(conversationId);
554
+ spawnClaude({ ...params, _attemptIdx: attemptIdx + 1, _firstAttemptAt: firstAttemptAt });
555
+ }, delay);
556
+ pendingRetries.set(conversationId, timer);
516
557
  return;
517
558
  }
518
559
  // Pass any partial token usage we observed before the failure so the
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)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@1presence/bridge",
3
- "version": "0.34.0",
3
+ "version": "0.35.0",
4
4
  "description": "Run 1Presence on your Mac and use your Claude.ai Pro subscription from any device",
5
5
  "bin": {
6
6
  "1presence-bridge": "dist/index.js"