@1presence/bridge 0.33.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,9 +111,59 @@ function renderHistoryMessage(msg) {
111
111
  }
112
112
  // ─── Active processes ─────────────────────────────────────────────────────────
113
113
  const active = new Map();
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
140
+ // Map a non-zero CLI exit + any captured "API Error:" line to a concise,
141
+ // user-facing Local Mode message. The raw upstream text stays in operator logs
142
+ // only — we never echo a wall of provider error JSON into the chat. Referring
143
+ // to "Claude Code" here is intentional and consistent with Local Mode's other
144
+ // operational errors (e.g. the "claude CLI not found" message): in Local Mode
145
+ // the user is knowingly running their own Claude Code install.
146
+ //
147
+ // NOTE on the 400 tool-use case: this is an open Claude Code print-mode
148
+ // regression (introduced in 2.1.19, still present in 2.1.146 — the current
149
+ // latest), so upgrading does NOT fix it. We deliberately do not suggest an
150
+ // upgrade; the automatic retry is the real mitigation and resending sometimes
151
+ // gets through.
152
+ function describeCliFailure(code, apiErrorText) {
153
+ const t = apiErrorText.trim();
154
+ if (/API Error:\s*400/i.test(t) && /(tool use|concurren|parallel)/i.test(t)) {
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';
156
+ }
157
+ if (/^API Error:/i.test(t)) {
158
+ return `Local Mode error from Claude Code: ${t.replace(/^API Error:\s*/i, '').trim()}`;
159
+ }
160
+ return `Local Mode stopped unexpectedly (claude exited with code ${code ?? 'unknown'}). Please try again.`;
161
+ }
114
162
  // ─── Spawn ────────────────────────────────────────────────────────────────────
115
163
  function spawnClaude(params) {
116
- 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;
165
+ const attemptIdx = params._attemptIdx ?? 0;
166
+ const firstAttemptAt = params._firstAttemptAt ?? Date.now();
117
167
  const systemPromptPath = (0, path_1.join)((0, os_1.tmpdir)(), `agent-${uid}.md`);
118
168
  const mcpConfigPath = (0, path_1.join)((0, os_1.tmpdir)(), `mcp-${uid}.json`);
119
169
  if (verbose) {
@@ -125,6 +175,13 @@ function spawnClaude(params) {
125
175
  process.stderr.write(paint('90', `[bridge:verbose] conversation: ${conversationId}`) + '\n');
126
176
  process.stderr.write(paint('90', `[bridge:verbose] history turns: ${history.length}`) + '\n');
127
177
  }
178
+ // Surface the user's UID before the session line in every mode — it's the
179
+ // Firestore doc prefix (`sessions/<uid>_<conversationId>`), so logging it
180
+ // makes a reported bridge failure correlatable to the stored session without
181
+ // having to ask which account hit it. The CLI's own `--session-id` is
182
+ // ephemeral and is NOT the Firestore conversationId, so the uid is the key
183
+ // join column when debugging.
184
+ process.stderr.write(`[bridge] user ${uid}\n`);
128
185
  // Debug transcript: lead with the user prompt for this turn (the clean
129
186
  // message, before the gateway's ephemeral-context prefix), plus the session
130
187
  // id (correlates with the chat URL / Firestore session doc) and a hint at
@@ -162,6 +219,19 @@ function spawnClaude(params) {
162
219
  existing.kill('SIGTERM');
163
220
  active.delete(conversationId);
164
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
+ }
165
235
  // Note: ephemeral context (vault_file_open / client_capabilities / synced_folders)
166
236
  // is injected into the last user message by the gateway BEFORE history is
167
237
  // sent over the WS. The bridge no longer constructs `userMessageText` —
@@ -198,6 +268,10 @@ function spawnClaude(params) {
198
268
  // across turns of a chat — even with --no-session-persistence. The
199
269
  // bridge passes the per-spawn `conversationId` here; the presence
200
270
  // sessionId is correlated separately via bridge logs and spool records.
271
+ // The CLI treats --session-id as "claim this new session ID" and rejects a
272
+ // reused id with "Session ID X is already in use". A retry is a fresh spawn,
273
+ // so it MUST use a new uuid; the first attempt keeps the correlation id.
274
+ const spawnSessionId = attemptIdx === 0 ? presenceSessionId : crypto.randomUUID();
201
275
  const args = [
202
276
  '--print',
203
277
  '--input-format', 'stream-json',
@@ -210,7 +284,7 @@ function spawnClaude(params) {
210
284
  '--mcp-config', mcpConfigPath,
211
285
  '--strict-mcp-config',
212
286
  '--no-session-persistence',
213
- '--session-id', presenceSessionId,
287
+ '--session-id', spawnSessionId,
214
288
  ];
215
289
  const pinnedModel = (0, config_1.getBridgeModel)();
216
290
  if (pinnedModel) {
@@ -266,6 +340,15 @@ function spawnClaude(params) {
266
340
  let extractedModel = null;
267
341
  let buffer = '';
268
342
  let killedForViolation = false;
343
+ // Retry/error-surfacing tracking for this attempt:
344
+ // - sawApiError: the CLI emitted an "API Error:" assistant text event (the
345
+ // way Claude Code reports an underlying API failure mid-turn).
346
+ // - apiErrorText: that text, captured for describeCliFailure().
347
+ // - producedRealOutput: any real assistant text or tool_use was emitted, so
348
+ // a later failure must NOT be retried (could double-run a side-effect).
349
+ let sawApiError = false;
350
+ let apiErrorText = '';
351
+ let producedRealOutput = false;
269
352
  proc.stdout.on('data', (chunk) => {
270
353
  buffer += chunk.toString('utf-8');
271
354
  const lines = buffer.split('\n');
@@ -282,6 +365,10 @@ function spawnClaude(params) {
282
365
  continue;
283
366
  }
284
367
  const type = event['type'];
368
+ // Set when this event is the CLI's "API Error:" turn — we neither forward
369
+ // it to the PWA nor let it reach the accumulator (it carries no real
370
+ // content and would poison history / show a raw error mid-stream).
371
+ let suppressEvent = false;
285
372
  // Extract model + key source info from the first system/init event.
286
373
  // No session-id persistence — Firestore is the only source of truth
287
374
  // now, and we pin --session-id to presenceSessionId on every spawn.
@@ -322,6 +409,7 @@ function spawnClaude(params) {
322
409
  let wroteText = false;
323
410
  for (const block of content) {
324
411
  if (block['type'] === 'tool_use') {
412
+ producedRealOutput = true;
325
413
  const toolName = block['name'];
326
414
  const toolId = block['id'];
327
415
  if (toolId)
@@ -370,13 +458,27 @@ function spawnClaude(params) {
370
458
  else if (block['type'] === 'text') {
371
459
  const text = block['text'];
372
460
  if (text) {
373
- if (debug) {
374
- // Full text, newlines intact the readable transcript.
375
- debugBlock('assistant', exports.SECTION_COLORS.assistant, text);
461
+ if (/^API Error:/i.test(text.trimStart())) {
462
+ // The CLI is reporting an underlying API failure as assistant
463
+ // text. Capture it for the user-facing message, and suppress
464
+ // the whole event so the raw error never reaches the PWA or
465
+ // the accumulator (the gateway also blanks it via
466
+ // cleanTurnText — this is the upstream defense).
467
+ sawApiError = true;
468
+ apiErrorText = text.trim();
469
+ suppressEvent = true;
470
+ process.stderr.write(paint(exports.SECTION_COLORS.result, `[bridge] ${text.replace(/\n+/g, ' ')}`) + '\n');
376
471
  }
377
472
  else {
378
- process.stderr.write(paint(exports.SECTION_COLORS.assistant, text.replace(/\n+/g, ' ')));
379
- wroteText = true;
473
+ producedRealOutput = true;
474
+ if (debug) {
475
+ // Full text, newlines intact — the readable transcript.
476
+ debugBlock('assistant', exports.SECTION_COLORS.assistant, text);
477
+ }
478
+ else {
479
+ process.stderr.write(paint(exports.SECTION_COLORS.assistant, text.replace(/\n+/g, ' ')));
480
+ wroteText = true;
481
+ }
380
482
  }
381
483
  }
382
484
  }
@@ -412,7 +514,8 @@ function spawnClaude(params) {
412
514
  if (typeof c === 'number')
413
515
  costUsd = c;
414
516
  }
415
- onEvent(event);
517
+ if (!suppressEvent)
518
+ onEvent(event);
416
519
  }
417
520
  });
418
521
  proc.stderr.on('data', (chunk) => {
@@ -434,9 +537,29 @@ function spawnClaude(params) {
434
537
  catch { /* ignore */ }
435
538
  }
436
539
  if (code !== 0 && code !== null) {
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);
557
+ return;
558
+ }
437
559
  // Pass any partial token usage we observed before the failure so the
438
- // PWA and the gateway's bridge usage store can still record it.
439
- onError(`claude exited with code ${code}`, usage, extractedModel);
560
+ // PWA and the gateway's bridge usage store can still record it. Surface a
561
+ // classified, user-readable message instead of the opaque exit code.
562
+ onError(describeCliFailure(code, apiErrorText), usage, extractedModel);
440
563
  }
441
564
  else {
442
565
  onDone(messageCount, costUsd, usage, extractedModel);
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.33.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"