@1presence/bridge 0.32.0 → 0.34.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
@@ -1,7 +1,9 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.SECTION_COLORS = void 0;
3
4
  exports.setVerbose = setVerbose;
4
5
  exports.setDebug = setDebug;
6
+ exports.paint = paint;
5
7
  exports.spawnClaude = spawnClaude;
6
8
  exports.killAll = killAll;
7
9
  const child_process_1 = require("child_process");
@@ -65,8 +67,12 @@ const USE_COLOR = process.stderr.isTTY === true && !process.env['NO_COLOR'];
65
67
  function paint(code, s) {
66
68
  return USE_COLOR ? `\x1b[${code}m${s}\x1b[0m` : s;
67
69
  }
68
- // ANSI colour codes per section, mirroring the admin debug palette.
69
- const DEBUG_COLORS = {
70
+ // ANSI colour codes per section, mirroring the admin debug palette. Shared
71
+ // across all three console modes (debug / verbose / normal) so the same kind
72
+ // of content is always the same colour — system prompts magenta, user prompts
73
+ // blue, assistant text green, tool inputs cyan, tool results yellow.
74
+ exports.SECTION_COLORS = {
75
+ system: '35', // magenta
70
76
  user: '34', // blue
71
77
  assistant: '32', // green
72
78
  input: '36', // cyan
@@ -97,7 +103,7 @@ function summariseHistoryBlock(block) {
97
103
  // can tell user turns from assistant turns at a glance — the missing
98
104
  // distinction that made replayed context unreadable in --debug.
99
105
  function renderHistoryMessage(msg) {
100
- const color = msg.role === 'user' ? DEBUG_COLORS.user : DEBUG_COLORS.assistant;
106
+ const color = msg.role === 'user' ? exports.SECTION_COLORS.user : exports.SECTION_COLORS.assistant;
101
107
  const body = typeof msg.content === 'string'
102
108
  ? msg.content
103
109
  : msg.content.map(summariseHistoryBlock).join('\n');
@@ -105,25 +111,64 @@ function renderHistoryMessage(msg) {
105
111
  }
106
112
  // ─── Active processes ─────────────────────────────────────────────────────────
107
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;
123
+ // Map a non-zero CLI exit + any captured "API Error:" line to a concise,
124
+ // user-facing Local Mode message. The raw upstream text stays in operator logs
125
+ // only — we never echo a wall of provider error JSON into the chat. Referring
126
+ // to "Claude Code" here is intentional and consistent with Local Mode's other
127
+ // operational errors (e.g. the "claude CLI not found" message): in Local Mode
128
+ // the user is knowingly running their own Claude Code install.
129
+ //
130
+ // NOTE on the 400 tool-use case: this is an open Claude Code print-mode
131
+ // regression (introduced in 2.1.19, still present in 2.1.146 — the current
132
+ // latest), so upgrading does NOT fix it. We deliberately do not suggest an
133
+ // upgrade; the automatic retry is the real mitigation and resending sometimes
134
+ // gets through.
135
+ function describeCliFailure(code, apiErrorText) {
136
+ const t = apiErrorText.trim();
137
+ 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.';
139
+ }
140
+ if (/^API Error:/i.test(t)) {
141
+ return `Local Mode error from Claude Code: ${t.replace(/^API Error:\s*/i, '').trim()}`;
142
+ }
143
+ return `Local Mode stopped unexpectedly (claude exited with code ${code ?? 'unknown'}). Please try again.`;
144
+ }
108
145
  // ─── Spawn ────────────────────────────────────────────────────────────────────
109
146
  function spawnClaude(params) {
110
147
  const { conversationId, presenceSessionId, text, uid, history, vaultFileOpen, clientCapabilities, syncedFolders, onEvent, onDone, onError } = params;
148
+ const attemptIdx = params._attemptIdx ?? 0;
111
149
  const systemPromptPath = (0, path_1.join)((0, os_1.tmpdir)(), `agent-${uid}.md`);
112
150
  const mcpConfigPath = (0, path_1.join)((0, os_1.tmpdir)(), `mcp-${uid}.json`);
113
151
  if (verbose) {
114
- process.stderr.write(`[bridge:verbose] cwd: ${BRIDGE_CWD}\n`);
115
- process.stderr.write(`[bridge:verbose] override md: ${(0, path_1.join)(BRIDGE_CWD, 'CLAUDE.md')}\n`);
116
- process.stderr.write(`[bridge:verbose] system prompt: ${systemPromptPath}\n`);
117
- process.stderr.write(`[bridge:verbose] mcp config: ${mcpConfigPath}\n`);
118
- process.stderr.write(`[bridge:verbose] session id: ${presenceSessionId}\n`);
119
- process.stderr.write(`[bridge:verbose] conversation: ${conversationId}\n`);
120
- process.stderr.write(`[bridge:verbose] history turns: ${history.length}\n`);
152
+ process.stderr.write(paint('90', `[bridge:verbose] cwd: ${BRIDGE_CWD}`) + '\n');
153
+ process.stderr.write(paint('90', `[bridge:verbose] override md: ${(0, path_1.join)(BRIDGE_CWD, 'CLAUDE.md')}`) + '\n');
154
+ process.stderr.write(paint('90', `[bridge:verbose] system prompt: ${systemPromptPath}`) + '\n');
155
+ process.stderr.write(paint('90', `[bridge:verbose] mcp config: ${mcpConfigPath}`) + '\n');
156
+ process.stderr.write(paint('90', `[bridge:verbose] session id: ${presenceSessionId}`) + '\n');
157
+ process.stderr.write(paint('90', `[bridge:verbose] conversation: ${conversationId}`) + '\n');
158
+ process.stderr.write(paint('90', `[bridge:verbose] history turns: ${history.length}`) + '\n');
121
159
  }
160
+ // Surface the user's UID before the session line in every mode — it's the
161
+ // Firestore doc prefix (`sessions/<uid>_<conversationId>`), so logging it
162
+ // makes a reported bridge failure correlatable to the stored session without
163
+ // having to ask which account hit it. The CLI's own `--session-id` is
164
+ // ephemeral and is NOT the Firestore conversationId, so the uid is the key
165
+ // join column when debugging.
166
+ process.stderr.write(`[bridge] user ${uid}\n`);
122
167
  // Debug transcript: lead with the user prompt for this turn (the clean
123
168
  // message, before the gateway's ephemeral-context prefix), plus the session
124
169
  // id (correlates with the chat URL / Firestore session doc) and a hint at
125
170
  // how much prior context is being replayed.
126
- if (debug) {
171
+ if (debug || verbose) {
127
172
  process.stderr.write(`\n${paint('1', `══ session ${presenceSessionId} ══`)}\n`);
128
173
  // `history` already ends with the new user prompt (gateway-appended). Render
129
174
  // every PRIOR message with its role colour so what the model saw as context
@@ -137,9 +182,9 @@ function spawnClaude(params) {
137
182
  for (const msg of prior)
138
183
  renderHistoryMessage(msg);
139
184
  }
140
- debugBlock('user · this turn', DEBUG_COLORS.user, text);
185
+ debugBlock('user · this turn', exports.SECTION_COLORS.user, text);
141
186
  }
142
- else if (!verbose) {
187
+ else {
143
188
  // Default mode is quiet, but always surface the session id once per turn so
144
189
  // it can be matched to the chat URL / Firestore session doc when debugging.
145
190
  process.stderr.write(`[bridge] session ${presenceSessionId}\n`);
@@ -192,6 +237,10 @@ function spawnClaude(params) {
192
237
  // across turns of a chat — even with --no-session-persistence. The
193
238
  // bridge passes the per-spawn `conversationId` here; the presence
194
239
  // sessionId is correlated separately via bridge logs and spool records.
240
+ // The CLI treats --session-id as "claim this new session ID" and rejects a
241
+ // reused id with "Session ID X is already in use". A retry is a fresh spawn,
242
+ // so it MUST use a new uuid; the first attempt keeps the correlation id.
243
+ const spawnSessionId = attemptIdx === 0 ? presenceSessionId : crypto.randomUUID();
195
244
  const args = [
196
245
  '--print',
197
246
  '--input-format', 'stream-json',
@@ -204,7 +253,7 @@ function spawnClaude(params) {
204
253
  '--mcp-config', mcpConfigPath,
205
254
  '--strict-mcp-config',
206
255
  '--no-session-persistence',
207
- '--session-id', presenceSessionId,
256
+ '--session-id', spawnSessionId,
208
257
  ];
209
258
  const pinnedModel = (0, config_1.getBridgeModel)();
210
259
  if (pinnedModel) {
@@ -260,6 +309,15 @@ function spawnClaude(params) {
260
309
  let extractedModel = null;
261
310
  let buffer = '';
262
311
  let killedForViolation = false;
312
+ // Retry/error-surfacing tracking for this attempt:
313
+ // - sawApiError: the CLI emitted an "API Error:" assistant text event (the
314
+ // way Claude Code reports an underlying API failure mid-turn).
315
+ // - apiErrorText: that text, captured for describeCliFailure().
316
+ // - producedRealOutput: any real assistant text or tool_use was emitted, so
317
+ // a later failure must NOT be retried (could double-run a side-effect).
318
+ let sawApiError = false;
319
+ let apiErrorText = '';
320
+ let producedRealOutput = false;
263
321
  proc.stdout.on('data', (chunk) => {
264
322
  buffer += chunk.toString('utf-8');
265
323
  const lines = buffer.split('\n');
@@ -276,6 +334,10 @@ function spawnClaude(params) {
276
334
  continue;
277
335
  }
278
336
  const type = event['type'];
337
+ // Set when this event is the CLI's "API Error:" turn — we neither forward
338
+ // it to the PWA nor let it reach the accumulator (it carries no real
339
+ // content and would poison history / show a raw error mid-stream).
340
+ let suppressEvent = false;
279
341
  // Extract model + key source info from the first system/init event.
280
342
  // No session-id persistence — Firestore is the only source of truth
281
343
  // now, and we pin --session-id to presenceSessionId on every spawn.
@@ -316,13 +378,14 @@ function spawnClaude(params) {
316
378
  let wroteText = false;
317
379
  for (const block of content) {
318
380
  if (block['type'] === 'tool_use') {
381
+ producedRealOutput = true;
319
382
  const toolName = block['name'];
320
383
  const toolId = block['id'];
321
384
  if (toolId)
322
385
  toolNames.set(toolId, toolName);
323
386
  if (debug) {
324
387
  // Clean transcript: a single coloured block with the full input.
325
- debugBlock(`tool → ${toolName}`, DEBUG_COLORS.input, formatPayload(block['input']));
388
+ debugBlock(`tool → ${toolName}`, exports.SECTION_COLORS.input, formatPayload(block['input']));
326
389
  }
327
390
  else {
328
391
  if (wroteText) {
@@ -330,10 +393,10 @@ function spawnClaude(params) {
330
393
  wroteText = false;
331
394
  }
332
395
  const prefix = toolName.startsWith('mcp__') ? '[mcp]' : '[tool]';
333
- process.stderr.write(`[bridge] ${prefix} ${toolName}\n`);
396
+ process.stderr.write(paint(exports.SECTION_COLORS.input, `[bridge] ${prefix} ${toolName}`) + '\n');
334
397
  if (verbose) {
335
398
  const input = block['input'];
336
- process.stderr.write(`[bridge:verbose] ─── input ${toolName} ───\n${formatPayload(input)}\n[bridge:verbose] ─── end input ───\n`);
399
+ process.stderr.write(paint(exports.SECTION_COLORS.input, `[bridge:verbose] ─── input ${toolName} ───\n${formatPayload(input)}\n[bridge:verbose] ─── end input ───`) + '\n');
337
400
  }
338
401
  }
339
402
  // Defense-in-depth: CLI flags (--tools "", --allowedTools, --strict-mcp-config,
@@ -364,13 +427,27 @@ function spawnClaude(params) {
364
427
  else if (block['type'] === 'text') {
365
428
  const text = block['text'];
366
429
  if (text) {
367
- if (debug) {
368
- // Full text, newlines intact the readable transcript.
369
- debugBlock('assistant', DEBUG_COLORS.assistant, text);
430
+ if (/^API Error:/i.test(text.trimStart())) {
431
+ // The CLI is reporting an underlying API failure as assistant
432
+ // text. Capture it for the user-facing message, and suppress
433
+ // the whole event so the raw error never reaches the PWA or
434
+ // the accumulator (the gateway also blanks it via
435
+ // cleanTurnText — this is the upstream defense).
436
+ sawApiError = true;
437
+ apiErrorText = text.trim();
438
+ suppressEvent = true;
439
+ process.stderr.write(paint(exports.SECTION_COLORS.result, `[bridge] ${text.replace(/\n+/g, ' ')}`) + '\n');
370
440
  }
371
441
  else {
372
- process.stderr.write(text.replace(/\n+/g, ' '));
373
- wroteText = true;
442
+ producedRealOutput = true;
443
+ if (debug) {
444
+ // Full text, newlines intact — the readable transcript.
445
+ debugBlock('assistant', exports.SECTION_COLORS.assistant, text);
446
+ }
447
+ else {
448
+ process.stderr.write(paint(exports.SECTION_COLORS.assistant, text.replace(/\n+/g, ' ')));
449
+ wroteText = true;
450
+ }
374
451
  }
375
452
  }
376
453
  }
@@ -391,10 +468,10 @@ function spawnClaude(params) {
391
468
  if (debug) {
392
469
  const name = toolNames.get(id) ?? id ?? 'result';
393
470
  const errFlag = block['is_error'] ? ' [error]' : '';
394
- debugBlock(`result ← ${name}${errFlag}`, DEBUG_COLORS.result, formatPayload(out));
471
+ debugBlock(`result ← ${name}${errFlag}`, exports.SECTION_COLORS.result, formatPayload(out));
395
472
  }
396
473
  else {
397
- process.stderr.write(`[bridge:verbose] ─── output ${id} ───\n${formatPayload(out)}\n[bridge:verbose] ─── end output ───\n`);
474
+ process.stderr.write(paint(exports.SECTION_COLORS.result, `[bridge:verbose] ─── output ${id} ───\n${formatPayload(out)}\n[bridge:verbose] ─── end output ───`) + '\n');
398
475
  }
399
476
  }
400
477
  }
@@ -406,7 +483,8 @@ function spawnClaude(params) {
406
483
  if (typeof c === 'number')
407
484
  costUsd = c;
408
485
  }
409
- onEvent(event);
486
+ if (!suppressEvent)
487
+ onEvent(event);
410
488
  }
411
489
  });
412
490
  proc.stderr.on('data', (chunk) => {
@@ -428,9 +506,19 @@ function spawnClaude(params) {
428
506
  catch { /* ignore */ }
429
507
  }
430
508
  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 });
516
+ return;
517
+ }
431
518
  // Pass any partial token usage we observed before the failure so the
432
- // PWA and the gateway's bridge usage store can still record it.
433
- onError(`claude exited with code ${code}`, usage, extractedModel);
519
+ // PWA and the gateway's bridge usage store can still record it. Surface a
520
+ // classified, user-readable message instead of the opaque exit code.
521
+ onError(describeCliFailure(code, apiErrorText), usage, extractedModel);
434
522
  }
435
523
  else {
436
524
  onDone(messageCount, costUsd, usage, extractedModel);
package/dist/index.js CHANGED
@@ -98,9 +98,9 @@ async function writeSystemPrompt(auth, agentSlug) {
98
98
  const systemPrompt = await fetchSystemPrompt(token, agentSlug);
99
99
  writeRestricted(tmpFile(`agent-${uid}.md`), systemPrompt);
100
100
  if (VERBOSE) {
101
- console.log('\n[bridge:verbose] ─── system prompt ───────────────────────');
102
- console.log(systemPrompt);
103
- console.log('[bridge:verbose] ─── end system prompt ───────────────────\n');
101
+ console.log((0, claude_1.paint)(claude_1.SECTION_COLORS.system, '\n[bridge:verbose] ─── system prompt ───────────────────────'));
102
+ console.log((0, claude_1.paint)(claude_1.SECTION_COLORS.system, systemPrompt));
103
+ console.log((0, claude_1.paint)(claude_1.SECTION_COLORS.system, '[bridge:verbose] ─── end system prompt ───────────────────\n'));
104
104
  }
105
105
  }
106
106
  function writeMcpConfig(auth) {
@@ -430,7 +430,7 @@ async function main() {
430
430
  console.log(`1Presence Bridge v${package_json_1.version}\n`);
431
431
  if (VERBOSE) {
432
432
  (0, claude_1.setVerbose)(true);
433
- console.log('[bridge:verbose] verbose logging enabled — system prompts, tool inputs, and tool outputs will be printed.\n');
433
+ console.log('[bridge:verbose] verbose logging enabled — system prompts (magenta), user prompts (blue), assistant text (green), tool inputs (cyan), and tool outputs (yellow) will be printed, colour-coded by kind.\n');
434
434
  }
435
435
  if (DEBUG) {
436
436
  (0, claude_1.setDebug)(true);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@1presence/bridge",
3
- "version": "0.32.0",
3
+ "version": "0.34.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"