@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 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
- // 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;
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 once automatically — sending the message again sometimes gets through.';
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 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 });
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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@1presence/bridge",
3
- "version": "0.34.0",
3
+ "version": "0.36.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"