@1presence/bridge 0.72.0 → 0.74.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
@@ -4,27 +4,6 @@ import { join } from 'path';
4
4
  import { z } from 'zod';
5
5
  import { query, createSdkMcpServer, tool } from '@anthropic-ai/claude-agent-sdk';
6
6
  import { resolveSessionFilePath } from './sessionPath.js';
7
- // ─── Engine ────────────────────────────────────────────────────────────────────
8
- //
9
- // The bridge drives the local Claude Code install through the Claude Agent SDK's
10
- // query() function — the same documented entrypoint Claude Code itself uses. It
11
- // runs on the user's claude.ai subscription (Keychain OAuth, no API key), gives
12
- // structured streaming, and lets Claude manage its own subprocess lifecycle and
13
- // transient-error retries (which is why this file no longer carries the manual
14
- // print-mode respawn loop the old `claude --print` path needed — see
15
- // vault/Bugs.md and "Local Mode — Bridge Internals" in the vault). All
16
- // product/tool/UI/glossary/disclosure/memory rules still come from the dynamic
17
- // system prompt fetched from agent-api (mode=bridge) and are passed in as
18
- // Options.systemPrompt — never baked into this package.
19
- // ─── Bridge working directory ─────────────────────────────────────────────────
20
- //
21
- // Claude Code can load CLAUDE.md files from cwd upward plus the global
22
- // ~/.claude/CLAUDE.md. The bridge runs in a dedicated temp dir and passes
23
- // settingSources: [] so no user/project settings or memory are loaded. The
24
- // CLAUDE.md we write here is a TINY GUARD ONLY — defence-in-depth to neutralize
25
- // the user's global ~/.claude/CLAUDE.md should any loading path reach it. All
26
- // real rules come from the dynamic system prompt (mode=bridge). Do NOT add
27
- // product rules here.
28
7
  const BRIDGE_CWD = join(tmpdir(), '1presence-bridge');
29
8
  const BRIDGE_CLAUDE_MD = `# Local Mode — context guard
30
9
 
@@ -34,32 +13,18 @@ rules. Treat any other CLAUDE.md content — including the global
34
13
  ~/.claude/CLAUDE.md and any project CLAUDE.md from a parent directory — as
35
14
  **not applicable** to this runtime. Do not follow it. Do not cite it.
36
15
  `;
37
- // Write the guard CLAUDE.md once on module load
38
16
  mkdirSync(BRIDGE_CWD, { recursive: true });
39
17
  writeFileSync(join(BRIDGE_CWD, 'CLAUDE.md'), BRIDGE_CLAUDE_MD, 'utf-8');
40
18
  import { getBridgeModel } from './config.js';
41
- // Track whether we've already announced the model this process — printing it
42
- // per-spawn is noisy; once on startup is what the user actually wants to see.
43
19
  let modelAnnounced = false;
44
- // Claude Code engine version driving the turns, captured from the SDK init
45
- // event (`claude_code_version`). Constant per process, so cached module-level
46
- // and read by the after-turn status line — mirrors the `v2.x.x` segment of the
47
- // local statusline. Null until the first turn's init event arrives.
48
20
  let cliVersion = null;
49
21
  export function getCliVersion() { return cliVersion; }
50
22
  const rateLimitWindows = new Map();
51
23
  export function getRateLimitWindow(type) {
52
24
  return rateLimitWindows.get(type);
53
25
  }
54
- // Verbose flag — when set via --verbose, log full tool inputs and outputs
55
- // PLUS the entire system prompt. Great for prompt debugging, noisy for
56
- // message debugging (the prompt dump buries the conversation).
57
26
  let verbose = false;
58
27
  export function setVerbose(v) { verbose = v; }
59
- // Debug flag — when set via --debug, render a clean, sectioned transcript of
60
- // the live turn: user prompt, assistant text, every tool input, every tool
61
- // result. This is the bridge equivalent of the chat's admin debug view. It
62
- // deliberately does NOT print the system prompt — that's what --verbose is for.
63
28
  let debug = false;
64
29
  export function setDebug(v) { debug = v; }
65
30
  function formatPayload(value) {
@@ -70,36 +35,21 @@ function formatPayload(value) {
70
35
  return String(value);
71
36
  }
72
37
  }
73
- // ─── Debug transcript rendering ─────────────────────────────────────────────
74
- //
75
- // A clean, scannable block per event — coloured header rule + body. Matches
76
- // the shape of the chat's admin debug bubbles (user / assistant / tool input /
77
- // tool result) so what you see locally mirrors what an admin sees in the app.
78
38
  const USE_COLOR = process.stderr.isTTY === true && !process.env['NO_COLOR'];
79
39
  export function paint(code, s) {
80
40
  return USE_COLOR ? `\x1b[${code}m${s}\x1b[0m` : s;
81
41
  }
82
- // ANSI colour codes per section, mirroring the admin debug palette. Shared
83
- // across all three console modes (debug / verbose / normal) so the same kind
84
- // of content is always the same colour — system prompts magenta, user prompts
85
- // blue, assistant text green, tool inputs cyan, tool results yellow.
86
42
  export const SECTION_COLORS = {
87
- system: '35', // magenta
88
- user: '34', // blue
89
- assistant: '32', // green
90
- input: '36', // cyan
91
- result: '33', // yellow
43
+ system: '35',
44
+ user: '34',
45
+ assistant: '32',
46
+ input: '36',
47
+ result: '33',
92
48
  };
93
49
  function debugBlock(label, colorCode, body) {
94
50
  const rule = `── ${label} `.padEnd(64, '─');
95
51
  process.stderr.write(`\n${paint(colorCode, rule)}\n${body.trimEnd()}\n`);
96
52
  }
97
- // Strip confabulated tool-call XML out of assistant text. Real tool calls arrive
98
- // as structured tool_use blocks, never as text — so when assistant text contains
99
- // <function_calls>/<function_results>/<invoke>, the model is role-playing the tool
100
- // protocol because it had no callable tools, and that raw XML (often invented
101
- // internal-looking content) must never reach the user. Kept as a tiny local copy
102
- // rather than importing @presence/shared so the published bridge stays decoupled.
103
53
  const TOOL_CALL_XML_RE = /<function_calls\b|<function_results\b|<invoke\b/i;
104
54
  function stripToolCallXml(text) {
105
55
  if (!text || !TOOL_CALL_XML_RE.test(text))
@@ -108,16 +58,11 @@ function stripToolCallXml(text) {
108
58
  .replace(/<function_calls>[\s\S]*?<\/function_calls>/gi, '')
109
59
  .replace(/<function_results>[\s\S]*?<\/function_results>/gi, '')
110
60
  .replace(/<invoke\b[\s\S]*?<\/invoke>/gi, '');
111
- // Truncated / unclosed opener: drop from the first stray opener to the end.
112
61
  out = out.replace(/<function_calls\b[\s\S]*$/i, '')
113
62
  .replace(/<function_results\b[\s\S]*$/i, '')
114
63
  .replace(/<invoke\b[\s\S]*$/i, '');
115
64
  return out.trim();
116
65
  }
117
- // Render one replayed-history content block as a single readable line for the
118
- // debug transcript. Tool calls and results are inlined so a history turn shows
119
- // exactly what the model received — text, the tools it ran, and what they
120
- // returned (including error flags) — not just an opaque "(replaying N turns)".
121
66
  function summariseHistoryBlock(block) {
122
67
  if (block.type === 'text')
123
68
  return block.text.trimEnd();
@@ -128,16 +73,12 @@ function summariseHistoryBlock(block) {
128
73
  const bytes = Math.floor((block.source?.data?.length ?? 0) * 0.75);
129
74
  return `🖼 image (${block.source?.media_type ?? 'unknown'}, ~${bytes} bytes)`;
130
75
  }
131
- // tool_result
132
76
  const body = typeof block.content === 'string'
133
77
  ? block.content
134
78
  : block.content.map((c) => c.text).join('');
135
79
  const errFlag = block.is_error ? ' [error]' : '';
136
80
  return `← ${block.tool_use_id}${errFlag} ${body}`;
137
81
  }
138
- // Render a full replayed-history message with its role colour so the operator
139
- // can tell user turns from assistant turns at a glance — the missing
140
- // distinction that made replayed context unreadable in --debug.
141
82
  function renderHistoryMessage(msg) {
142
83
  const color = msg.role === 'user' ? SECTION_COLORS.user : SECTION_COLORS.assistant;
143
84
  const body = typeof msg.content === 'string'
@@ -145,23 +86,9 @@ function renderHistoryMessage(msg) {
145
86
  : msg.content.map(summariseHistoryBlock).join('\n');
146
87
  debugBlock(`${msg.role} · history`, color, body);
147
88
  }
148
- // ─── Active turns ───────────────────────────────────────────────────────────────
149
- //
150
- // conversationId → AbortController for the in-flight query(). Aborting cancels
151
- // the turn (supersede on a new message, or the Stop button via the gateway's
152
- // `cancel` frame). The SDK's query() loop ends when its controller aborts.
153
89
  const active = new Map();
154
- // Map a thrown query() error / captured "API Error:" text to a concise,
155
- // user-facing Local Mode message. The raw upstream text stays in operator logs
156
- // only — we never echo a wall of provider error JSON into the chat. Referring
157
- // to "Claude Code" here is intentional and consistent with Local Mode's other
158
- // operational errors: in Local Mode the user is knowingly running their own
159
- // Claude Code install.
160
90
  function describeCliFailure(apiErrorText, authFailure) {
161
91
  const t = apiErrorText.trim();
162
- // Auth/credential failure (401/403). Local Mode runs the user's own Claude
163
- // Code, so naming it (and /login) is intentional — this is the only place
164
- // that can tell them how to recover. Takes precedence over generic branches.
165
92
  if (authFailure) {
166
93
  return 'Local Mode could not sign in to Claude Code on this machine. Open a terminal, run `claude` and sign in (or run /login inside Claude Code), then send your message again.';
167
94
  }
@@ -173,11 +100,6 @@ function describeCliFailure(apiErrorText, authFailure) {
173
100
  }
174
101
  return 'Local Mode stopped unexpectedly. Please try again.';
175
102
  }
176
- // Join every non-empty error fragment the SDK might carry (a `result` string,
177
- // an `errors: string[]`, an error enum, a request_id, …) into one de-duplicated
178
- // line. A 4xx/5xx surfaces its detail unpredictably: `result` rides on the
179
- // success-shaped error result, `errors[]` on SDKResultError, the coarse bucket
180
- // on `assistant.error` — so we gather from all of them rather than trusting one.
181
103
  function joinErrorDetail(...parts) {
182
104
  const seen = new Set();
183
105
  const out = [];
@@ -192,26 +114,9 @@ function joinErrorDetail(...parts) {
192
114
  }
193
115
  return out.join(' | ');
194
116
  }
195
- // Lines the SDK/CLI writes to its own stderr that look like a failure — surfaced
196
- // even outside verbose mode so a turn that dies upstream leaves a trail.
197
117
  const SDK_STDERR_ERROR_RE = /\b(error|exception|fail(?:ed|ure)?|invalid|unauthor|forbidden|refus|denied|40[0-9]|429|5\d\d|overloaded|rate.?limit)\b/i;
198
- // The remote 1Presence MCP server's key (matches index.ts writeMcpConfig +
199
- // the `mcp__1presence__*` allowlist). reconnectMcpServer() takes this name.
200
118
  const REMOTE_MCP_SERVER_NAME = '1presence';
201
- // Signature of a dropped remote MCP session. The pod binds each MCP session to a
202
- // single long-lived SSE `GET /mcp` stream held in its memory; if that stream
203
- // drops mid-run (transient network blip on a long workflow stage), the pod
204
- // deletes the session and every later `POST /mcp/message?sessionId=…` returns
205
- // HTTP 404 "session not found or expired". The SDK does NOT auto-retry an
206
- // expired MCP session (sdk.d.ts: "Session expiry is not retried automatically;
207
- // callers can mcp_reconnect and retry") — it surfaces the 404 as a tool_result
208
- // error to the model, which then dead-loops calling tools against the same dead
209
- // session for the rest of the turn. Detecting this lets us reconnect the server
210
- // so the model's next attempt re-handshakes a fresh session and recovers.
211
119
  const MCP_SESSION_EXPIRED_RE = /session not found or expired|Error POSTing to endpoint \(HTTP 404\)/i;
212
- // Pull every text fragment out of a tool_result `content` (string OR the MCP
213
- // block-array shape `[{type:'text',text:'…'}]`) so we can scan it for the
214
- // session-expiry signature above.
215
120
  function toolResultText(content) {
216
121
  if (typeof content === 'string')
217
122
  return content;
@@ -224,13 +129,6 @@ function toolResultText(content) {
224
129
  }
225
130
  return '';
226
131
  }
227
- /**
228
- * Copy for an actionable rate-limit notice. The SDK emits `rate_limit_event`
229
- * whenever rate-limit info CHANGES — including the routine `allowed` case on
230
- * (nearly) every turn — so the caller must drop `allowed` and only invoke this
231
- * for `allowed_warning` / `rejected`. `resetsAt` is a Unix timestamp; the SDK
232
- * uses seconds, but we accept ms too in case that changes upstream.
233
- */
234
132
  function formatRateLimitNotice(status, resetsAt) {
235
133
  const when = typeof resetsAt === 'number' && resetsAt > 0
236
134
  ? new Date(resetsAt < 1e12 ? resetsAt * 1000 : resetsAt)
@@ -245,34 +143,13 @@ function formatRateLimitNotice(status, resetsAt) {
245
143
  ? `Approaching your usage limit — window resets around ${when}.`
246
144
  : 'Approaching your usage limit for this period.';
247
145
  }
248
- /**
249
- * Copy for an in-flight retry notice. The SDK retries retryable upstream
250
- * failures (529 overloaded / 5xx / some 429s) with exponential backoff, which
251
- * can leave a turn apparently frozen for tens of seconds. We surface a concise,
252
- * ephemeral status line so the user knows the turn is alive and waiting — never
253
- * the raw provider error body. A 529 is upstream *overload*, not the user's own
254
- * quota, so we phrase it as "busy", not "rate limited".
255
- */
256
146
  function formatRetryNotice(attempt, maxRetries, delayMs) {
257
147
  const secs = typeof delayMs === 'number' && delayMs > 0 ? Math.round(delayMs / 1000) : null;
258
148
  const tail = secs ? ` Retrying in ~${secs}s` : ' Retrying';
259
149
  const of = maxRetries > 0 ? ` (attempt ${attempt}/${maxRetries})` : '';
260
150
  return `Claude's servers are busy.${tail}${of}…`;
261
151
  }
262
- // ─── Prompt construction ─────────────────────────────────────────────────────────
263
- //
264
- // The gateway pushes the FULL conversation (sanitised via @presence/shared
265
- // toModelMessages) and `history` already ends with the new user turn. The SDK's
266
- // streaming input only triggers an assistant turn for user messages whose
267
- // `shouldQuery` is not false — so we replay every PRIOR turn with
268
- // shouldQuery:false (appended to the transcript, no turn generated), inject
269
- // assistant turns verbatim (carrying their tool_use blocks), and let ONLY the
270
- // final/live user turn run. This preserves stateless structured replay: no
271
- // session resume, no flat-text collapse, no local jsonl — Firestore stays the
272
- // single source of truth, exactly as the CLI stdin replay did.
273
152
  function buildPromptMessages(history) {
274
- // Index of the live user turn — the last user-role message. The gateway
275
- // always appends it; the scan is defensive against an unexpected tail.
276
153
  let liveIdx = -1;
277
154
  for (let i = history.length - 1; i >= 0; i--) {
278
155
  if (history[i].role === 'user') {
@@ -282,13 +159,10 @@ function buildPromptMessages(history) {
282
159
  }
283
160
  const out = [];
284
161
  history.forEach((msg, i) => {
285
- // Normalise to array-of-blocks (a bare string becomes a single text block).
286
162
  const content = Array.isArray(msg.content)
287
163
  ? msg.content
288
164
  : [{ type: 'text', text: typeof msg.content === 'string' ? msg.content : '' }];
289
165
  if (msg.role === 'assistant') {
290
- // Injected verbatim — runtime accepts a {type:'assistant'} message on the
291
- // input stream and appends it to the transcript (it never triggers a turn).
292
166
  out.push({ type: 'assistant', message: { role: 'assistant', content }, parent_tool_use_id: null });
293
167
  }
294
168
  else {
@@ -307,7 +181,6 @@ async function* promptStream(messages) {
307
181
  for (const m of messages)
308
182
  yield m;
309
183
  }
310
- // ─── Spawn (drive one turn through the SDK) ──────────────────────────────────────
311
184
  export function spawnClaude(params) {
312
185
  const { conversationId, presenceSessionId, text, uid, history, vaultFileOpen, clientCapabilities, syncedFolders, model: perTurnModel, onEvent, onDone, onError, onNotice } = params;
313
186
  const systemPromptPath = join(tmpdir(), `agent-${uid}.md`);
@@ -321,12 +194,7 @@ export function spawnClaude(params) {
321
194
  process.stderr.write(paint('90', `[bridge:verbose] conversation: ${conversationId}`) + '\n');
322
195
  process.stderr.write(paint('90', `[bridge:verbose] history turns: ${history.length}`) + '\n');
323
196
  }
324
- // Surface the user's UID before the session line in every mode — it's the
325
- // Firestore doc prefix (`sessions/<uid>_<conversationId>`), so logging it
326
- // makes a reported bridge failure correlatable to the stored session.
327
197
  process.stderr.write(`[bridge] user ${uid}\n`);
328
- // Debug transcript: lead with the prior context (replayed history) then the
329
- // live user prompt. `history` already ends with the new user turn.
330
198
  if (debug || verbose) {
331
199
  process.stderr.write(`\n${paint('1', `══ session ${presenceSessionId} ══`)}\n`);
332
200
  const tail = history[history.length - 1];
@@ -341,15 +209,10 @@ export function spawnClaude(params) {
341
209
  else {
342
210
  process.stderr.write(`[bridge] session ${presenceSessionId}\n`);
343
211
  }
344
- // ephemeral context (vault_file_open / client_capabilities / synced_folders) is
345
- // injected into the last user message by the gateway BEFORE history is sent —
346
- // these params are retained for backward-compatible logging only.
347
212
  void vaultFileOpen;
348
213
  void clientCapabilities;
349
214
  void syncedFolders;
350
215
  void text;
351
- // Supersede any in-flight turn for this conversation (user sent a follow-up
352
- // before the previous turn finished). The latest intent wins.
353
216
  const existing = active.get(conversationId);
354
217
  if (existing) {
355
218
  process.stderr.write(`[bridge] superseding active conversation ${conversationId}\n`);
@@ -358,65 +221,30 @@ export function spawnClaude(params) {
358
221
  }
359
222
  const abort = new AbortController();
360
223
  active.set(conversationId, abort);
361
- // tool_use_id → tool name, so a tool_result block (which only carries the id)
362
- // can be labelled with the tool it answers in the debug transcript.
363
224
  const toolNames = new Map();
364
- // Per-turn accounting.
365
225
  let sessionIdExtracted = false;
366
226
  let messageCount = 0;
367
227
  let costUsd = 0;
368
228
  let usage = null;
369
- // Prompt size of the MOST RECENT assistant call (input + both cache buckets),
370
- // overwritten on each assistant event so it ends on the turn's final, fullest
371
- // call. This — not the summed `usage` — is the current context fill the status
372
- // line's 🧠 segment reports against the model's window.
373
229
  let lastContextTokens = 0;
374
230
  let extractedModel = null;
375
- // Captured from the SDK's system/init event; gates read_session_file so it can
376
- // only reach files under THIS session's folder. Read by the in-process tool
377
- // handler, which fires later (when the model invokes it), by which time init
378
- // has arrived.
379
231
  let currentSessionId = null;
380
232
  let killedForViolation = false;
381
233
  let sawApiError = false;
382
234
  let sawAuthFailure = false;
383
235
  let apiErrorText = '';
384
236
  let producedRealOutput = false;
385
- // Allow only the 1Presence MCP surface to execute. Built-in tools are disabled
386
- // via extraArgs `--tools ""`; this is the runtime safety net (a hard deny that
387
- // runs before any execution) for anything that slips past. Our MCP tools are
388
- // auto-approved via allowedTools, so this callback only ever fires to deny.
389
237
  const canUseTool = async (toolName, input) => {
390
- // mcp__local__ is the in-process server (read_session_file) — local-only,
391
- // mechanical, no network/pod hop. Everything else must be a 1Presence tool.
392
238
  if (toolName.startsWith('mcp__1presence__') || toolName.startsWith('mcp__local__')) {
393
239
  return { behavior: 'allow', updatedInput: input };
394
240
  }
395
241
  return { behavior: 'deny', message: `Tool ${toolName} is not allowed in Local Mode`, interrupt: true };
396
242
  };
397
- // Strip API key so Claude Code uses the user's claude.ai subscription (OAuth
398
- // credentials in the Keychain), not an API key that would bill a separate
399
- // account. Options.env REPLACES the subprocess env, so spread the rest through.
400
243
  const { ANTHROPIC_API_KEY: _stripped, ...safeEnv } = process.env;
401
- // When an MCP tool result is "large", Claude Code spills the full result to a
402
- // tool-results/*.json file under the session folder and hands the model a
403
- // `<persisted-output>` stub telling it to read that file. Local Mode disables
404
- // every built-in tool (`tools: []` below), so there is no Read — historically
405
- // the model reached for vault_read (which reads the cloud vault, not local
406
- // disk) and stalled. The fix is the in-process read_session_file tool (see the
407
- // `local` MCP server below), which reads the spilled file directly. We also
408
- // best-effort raise MAX_MCP_OUTPUT_TOKENS so smaller large-ish results pass
409
- // inline and never spill in the first place — but treat it as advisory: the
410
- // env var is not honoured by every SDK version (it was a no-op on 0.3.x, which
411
- // is what made read_session_file necessary). Operator override wins.
412
244
  if (!safeEnv['MAX_MCP_OUTPUT_TOKENS']) {
413
245
  safeEnv['MAX_MCP_OUTPUT_TOKENS'] = '200000';
414
246
  }
415
247
  const pinnedModel = perTurnModel ?? getBridgeModel();
416
- // Process one translated raw stream-json event: bookkeeping + forward. Mirrors
417
- // the old CLI stdout parser so the gateway/accumulator see identical shapes.
418
- // Returns false when the event must be suppressed (errors) or the turn was
419
- // killed for a tool violation.
420
248
  function handleEvent(event) {
421
249
  const type = event['type'];
422
250
  if (!sessionIdExtracted && type === 'system' && event['subtype'] === 'init') {
@@ -478,9 +306,6 @@ export function spawnClaude(params) {
478
306
  process.stderr.write(paint(SECTION_COLORS.input, `[bridge:verbose] ─── input ${toolName} ───\n${formatPayload(block['input'])}\n[bridge:verbose] ─── end input ───`) + '\n');
479
307
  }
480
308
  }
481
- // Defense-in-depth: canUseTool + --tools "" + strictMcpConfig should
482
- // make a non-1Presence tool unreachable. If one appears anyway, kill
483
- // the turn so any side effect in flight is the only damage done.
484
309
  const isMcp1presence = toolName.startsWith('mcp__1presence__');
485
310
  const isMcpLocal = toolName.startsWith('mcp__local__');
486
311
  const isBareName = /^[a-z][a-z0-9_]*$/.test(toolName);
@@ -496,10 +321,6 @@ export function spawnClaude(params) {
496
321
  }
497
322
  else if (block['type'] === 'text') {
498
323
  let blockText = block['text'];
499
- // Drop confabulated tool-call XML before this event is forwarded to
500
- // the gateway (onEvent forwards THIS object, so mutate it in place).
501
- // Happens when a turn ran with no callable tools and the model
502
- // role-played the protocol in prose. See vault/Bugs.md 2026-05-28.
503
324
  if (blockText && TOOL_CALL_XML_RE.test(blockText)) {
504
325
  const cleaned = stripToolCallXml(blockText);
505
326
  if (cleaned !== blockText) {
@@ -509,10 +330,6 @@ export function spawnClaude(params) {
509
330
  }
510
331
  }
511
332
  if (blockText) {
512
- // The CLI/SDK can report auth/API failures as a synthetic assistant
513
- // text turn whose wording varies. Detect by the structured signal
514
- // (event.error) plus a wording fallback, so it's classified rather
515
- // than leaking raw into the chat as if the model had said it.
516
333
  const isSynthetic = msg?.['model'] === '<synthetic>';
517
334
  const isAuthFailure = event['error'] === 'authentication_failed' ||
518
335
  (isSynthetic && /(api error:\s*40[13]\b|invalid (api key|authentication)|please run \/login|failed to authenticate|unauthor)/i.test(blockText));
@@ -522,7 +339,7 @@ export function spawnClaude(params) {
522
339
  if (isAuthFailure)
523
340
  sawAuthFailure = true;
524
341
  process.stderr.write(paint(SECTION_COLORS.result, `[bridge] ${blockText.replace(/\n+/g, ' ')}`) + '\n');
525
- return false; // suppress — never forward a raw error turn
342
+ return false;
526
343
  }
527
344
  producedRealOutput = true;
528
345
  if (debug) {
@@ -560,8 +377,6 @@ export function spawnClaude(params) {
560
377
  }
561
378
  }
562
379
  if (type === 'result') {
563
- // total_cost_usd is the SDK's notional figure (0 on the no-op history
564
- // append cycle; the real number on the live turn). Keep the largest seen.
565
380
  const c = event['total_cost_usd'] ?? event['cost_usd'];
566
381
  if (typeof c === 'number' && c > costUsd)
567
382
  costUsd = c;
@@ -570,21 +385,10 @@ export function spawnClaude(params) {
570
385
  const status = event['api_error_status'];
571
386
  if (status === 401 || status === 403)
572
387
  sawAuthFailure = true;
573
- // Gather the reason from EVERY error-bearing field, not just `result`
574
- // (empty on most 4xx/5xx). `errors[]` rides on SDKResultError. Fold into
575
- // apiErrorText, keeping any coarse enum the assistant.error path set.
576
388
  const detail = joinErrorDetail(event['result'], event['errors']);
577
389
  apiErrorText = joinErrorDetail(apiErrorText, detail) || apiErrorText;
578
390
  const subtype = event['subtype'] ? ` subtype=${event['subtype']}` : '';
579
- // Operator visibility — a result-borne error would otherwise reach the
580
- // user as a chat error with NOTHING in the bridge logs (the failure
581
- // mode that made the empty-content `invalid_request` regression so hard
582
- // to diagnose). Logged in ALL modes (not just verbose). Mechanical log
583
- // only; no product logic.
584
391
  process.stderr.write(paint(SECTION_COLORS.result, `[bridge] result error${status != null ? ` (${status})` : ''}${subtype}: ${apiErrorText || 'unknown'}`) + '\n');
585
- // When no field carried a reason (the SDK bucketed it as bare "unknown"),
586
- // dump the raw result object so nothing is silently swallowed — the only
587
- // remaining place a clue could hide. Bounded so it can't flood the log.
588
392
  if (!detail) {
589
393
  let raw;
590
394
  try {
@@ -599,8 +403,6 @@ export function spawnClaude(params) {
599
403
  }
600
404
  return true;
601
405
  }
602
- // Drive the turn. Synchronous spawnClaude returns immediately; the SDK loop
603
- // runs in this async IIFE and fires the same callbacks the CLI path did.
604
406
  void (async () => {
605
407
  let systemPrompt;
606
408
  let mcpServers;
@@ -614,15 +416,6 @@ export function spawnClaude(params) {
614
416
  onError(`Local Mode setup files unavailable: ${err.message}`, null, null);
615
417
  return;
616
418
  }
617
- // In-process MCP server for purely-LOCAL operations that cannot run in the
618
- // pod (the file lives on this machine, not the agent pod). Currently one
619
- // tool: read_session_file, the recovery path for the SDK's large-output
620
- // spill (see resolveSessionFilePath above). It is the ONLY way to read a
621
- // file on the bridge host — built-ins stay disabled (`tools: []`). The tool
622
- // body is mechanical (read a confined file); no product logic or secrets, so
623
- // it is safe in this public package. alwaysLoad keeps it in the turn-1
624
- // prompt instead of behind tool-search, so the model can use it the moment
625
- // it is handed a persisted-output stub.
626
419
  {
627
420
  const localServer = createSdkMcpServer({
628
421
  name: 'local',
@@ -651,28 +444,18 @@ export function spawnClaude(params) {
651
444
  mcpServers = { ...mcpServers, local: localServer };
652
445
  }
653
446
  const options = {
654
- systemPrompt, // custom string → replaces the default Claude Code prompt
447
+ systemPrompt,
655
448
  mcpServers: mcpServers,
656
- strictMcpConfig: true, // only our MCP server, ignore project/user/plugin MCP
657
- settingSources: [], // no user/project settings or memory
658
- allowedTools: ['mcp__1presence__*', 'mcp__local__read_session_file'], // auto-approve our MCP surface (no prompt)
659
- canUseTool, // hard deny anything else
660
- tools: [], // disable ALL built-in tools; MCP tools come via mcpServers and survive.
661
- // (The old `extraArgs: { tools: '' }` passed a malformed --tools "" that
662
- // cleared the whole tool surface — including MCP — so the model had no
663
- // callable tools and confabulated tool calls as text. See vault/Bugs.md.)
449
+ strictMcpConfig: true,
450
+ settingSources: [],
451
+ allowedTools: ['mcp__1presence__*', 'mcp__local__read_session_file'],
452
+ canUseTool,
453
+ tools: [],
664
454
  cwd: BRIDGE_CWD,
665
455
  abortController: abort,
666
- includePartialMessages: true, // token-level streaming: emit stream_event partial deltas so
667
- // Local Mode streams like hosted (see the stream_event case below).
668
- // The whole `assistant` message STILL arrives and stays
669
- // authoritative for tool_use / usage / storage / the tool-XML scrub.
456
+ includePartialMessages: true,
670
457
  permissionMode: 'default',
671
458
  env: safeEnv,
672
- // Surface the SDK/CLI's own stderr. In verbose mode pass everything; in
673
- // normal mode pass only error-looking lines so an upstream failure still
674
- // leaves a trail (the SDK abstracts the raw API body into an enum, so its
675
- // stderr is sometimes the only place the real reason appears).
676
459
  stderr: (line) => {
677
460
  const t = line.trim();
678
461
  if (t && (verbose || SDK_STDERR_ERROR_RE.test(t)))
@@ -681,9 +464,6 @@ export function spawnClaude(params) {
681
464
  ...(pinnedModel ? { model: pinnedModel } : {}),
682
465
  };
683
466
  const promptMessages = buildPromptMessages(history);
684
- // Throttle remote-MCP reconnects: a single dropped session produces a burst
685
- // of 404 tool_results (the model keeps trying), so reconnect at most once per
686
- // window to avoid a reconnect storm. A reconnect is in-flight guard too.
687
467
  let lastMcpReconnectAt = 0;
688
468
  let mcpReconnecting = false;
689
469
  const MCP_RECONNECT_THROTTLE_MS = 10_000;
@@ -703,8 +483,6 @@ export function spawnClaude(params) {
703
483
  try {
704
484
  const q = query({ prompt: promptStream(promptMessages), options });
705
485
  for await (const m of q) {
706
- // Skip echoed input replays — they would double-count in the accumulator
707
- // and re-stream prior turns to the PWA.
708
486
  if (m.isReplay)
709
487
  continue;
710
488
  switch (m.type) {
@@ -717,17 +495,8 @@ export function spawnClaude(params) {
717
495
  onEvent(event);
718
496
  }
719
497
  else if (subtype === 'api_retry') {
720
- // The SDK retries retryable API failures (5xx / overloaded / some
721
- // 429s) before giving up, with exponential backoff that can leave a
722
- // turn apparently frozen for tens of seconds. Log each attempt with
723
- // its status + bucket so a turn that eventually dies after retries
724
- // leaves a full trail — in all modes, not just verbose.
725
498
  const r = m;
726
499
  process.stderr.write(paint(SECTION_COLORS.result, `[bridge] api retry ${r.attempt ?? '?'}/${r.max_retries ?? '?'}${r.error_status != null ? ` (${r.error_status})` : ''}: ${r.error ?? 'unknown'}${r.retry_delay_ms != null ? `, next in ${r.retry_delay_ms}ms` : ''}`) + '\n');
727
- // Surface an ephemeral status line to the chat so the user sees the
728
- // turn is alive and waiting, not frozen. Gated to attempt ≥ 2 (and a
729
- // perceptible delay) so a single sub-second transient self-heals
730
- // silently; only a sustained upstream overload reaches the user.
731
500
  const attempt = r.attempt ?? 0;
732
501
  if (attempt >= 2 || (r.retry_delay_ms ?? 0) >= 2000) {
733
502
  onNotice?.(formatRetryNotice(attempt, r.max_retries ?? 0, r.retry_delay_ms));
@@ -737,14 +506,10 @@ export function spawnClaude(params) {
737
506
  }
738
507
  case 'assistant': {
739
508
  const am = m;
740
- // Structured error signal — classify, do not forward as a real turn.
741
509
  if (am.error) {
742
510
  sawApiError = true;
743
511
  if (am.error === 'authentication_failed' || am.error === 'oauth_org_not_allowed')
744
512
  sawAuthFailure = true;
745
- // Pull any extra reason off the synthetic error message + the
746
- // request_id (the handle to look the failure up upstream) so the
747
- // log/chat error isn't just the coarse `unknown` bucket.
748
513
  const msgText = Array.isArray(am.message?.['content'])
749
514
  ? am.message['content']
750
515
  .filter((b) => b['type'] === 'text' && typeof b['text'] === 'string')
@@ -763,14 +528,11 @@ export function spawnClaude(params) {
763
528
  if (handleEvent(event))
764
529
  onEvent(event);
765
530
  if (killedForViolation)
766
- return; // handleEvent already aborted + onError
531
+ return;
767
532
  break;
768
533
  }
769
534
  case 'user': {
770
535
  const um = m;
771
- // Detect a dropped remote-MCP session in any tool_result and re-handshake
772
- // so the model's next tool call lands on a live session instead of
773
- // 404-looping for the rest of the turn (the SDK won't auto-retry it).
774
536
  const content = um.message?.['content'];
775
537
  if (Array.isArray(content)) {
776
538
  for (const block of content) {
@@ -795,7 +557,7 @@ export function spawnClaude(params) {
795
557
  is_error: rm['is_error'],
796
558
  api_error_status: rm['api_error_status'],
797
559
  result: rm['result'],
798
- errors: rm['errors'], // string[] on SDKResultError — the reason on a non-success result
560
+ errors: rm['errors'],
799
561
  };
800
562
  if (handleEvent(event))
801
563
  onEvent(event);
@@ -811,15 +573,8 @@ export function spawnClaude(params) {
811
573
  break;
812
574
  }
813
575
  case 'rate_limit_event': {
814
- // The SDK emits this whenever rate-limit info CHANGES — including the
815
- // routine `allowed` case on (nearly) every turn, which previously
816
- // spammed a phantom "pausing" notice (see vault/Bugs.md). Only surface
817
- // a notice when the user is actually warned or throttled.
818
576
  const info = m.rate_limit_info;
819
577
  const status = info?.status;
820
- // Retain the latest reading for this window so the after-turn status
821
- // line can render the full 5h/7d picture even though each event only
822
- // carries one window. utilization is a 0–100 used-percentage.
823
578
  if (info?.rateLimitType && typeof info.utilization === 'number') {
824
579
  rateLimitWindows.set(info.rateLimitType, {
825
580
  utilization: info.utilization,
@@ -828,22 +583,10 @@ export function spawnClaude(params) {
828
583
  });
829
584
  }
830
585
  if (status === 'allowed_warning' || status === 'rejected') {
831
- // Admin-only ephemeral notice — jargon is fine in Local Mode.
832
586
  onNotice?.(formatRateLimitNotice(status, info?.resetsAt));
833
587
  }
834
588
  break;
835
589
  }
836
- // Partial assistant deltas (includePartialMessages:true). Forward ONLY
837
- // text deltas, as the gateway translator's {type:'text'} partial event,
838
- // so Local Mode paints token-by-token like hosted. The whole `assistant`
839
- // message still arrives after and stays authoritative for tool_use, usage,
840
- // storage and the tool-XML scrub — so partials bypass handleEvent (they
841
- // carry text only; the whole message is what handleEvent scans). Other
842
- // partial event kinds (input_json_delta, thinking, message_start/stop) are
843
- // SDK-internal and dropped. NOTE: partials are forwarded pre-scrub — the
844
- // confab-tool-XML guard in handleEvent only cleans the whole message
845
- // (storage). That guard exists for the no-callable-tools case; this bridge
846
- // has callable tools, so confabulated XML shouldn't occur in a live turn.
847
590
  case 'stream_event': {
848
591
  const se = m;
849
592
  const ev = se.event;
@@ -852,9 +595,6 @@ export function spawnClaude(params) {
852
595
  }
853
596
  break;
854
597
  }
855
- // Everything else (status, hooks, notifications, thinking tokens, …) is
856
- // SDK-internal — not part of the CLI event contract the gateway/accumulator
857
- // understand, so it is dropped.
858
598
  default:
859
599
  break;
860
600
  }
@@ -862,16 +602,11 @@ export function spawnClaude(params) {
862
602
  }
863
603
  catch (err) {
864
604
  active.delete(conversationId);
865
- // Aborted by supersede or the Stop button — no error to surface.
866
605
  if (abort.signal.aborted)
867
606
  return;
868
607
  if (killedForViolation)
869
608
  return;
870
609
  const message = err?.message ?? String(err);
871
- // Log the raw thrown error IN FULL before onError sanitises it for chat —
872
- // describeCliFailure deliberately strips provider detail from the
873
- // user-facing copy, so the operator log is the only place the full reason
874
- // (incl. stack) survives. All modes, not just verbose.
875
610
  const stack = err?.stack;
876
611
  process.stderr.write(paint(SECTION_COLORS.result, `[bridge] query() threw: ${stack || message}`) + '\n');
877
612
  if (/40[13]\b|unauthor|invalid (api key|authentication)|please run \/login/i.test(message)) {
@@ -884,9 +619,9 @@ export function spawnClaude(params) {
884
619
  }
885
620
  active.delete(conversationId);
886
621
  if (killedForViolation)
887
- return; // already errored
622
+ return;
888
623
  if (abort.signal.aborted)
889
- return; // superseded/cancelled mid-stream
624
+ return;
890
625
  if (sawAuthFailure || (sawApiError && !producedRealOutput)) {
891
626
  onError(describeCliFailure(apiErrorText, sawAuthFailure), usage, extractedModel);
892
627
  }
@@ -901,13 +636,6 @@ export function killAll() {
901
636
  }
902
637
  active.clear();
903
638
  }
904
- /**
905
- * Stop one in-flight turn (the Stop button, relayed by the gateway as a
906
- * `cancel` frame). Aborts the running query() for this conversation so no
907
- * further stream events are produced. Mirrors the supersede path in spawnClaude.
908
- * Returns true if something was actually stopped. Mechanical only — no product
909
- * logic lives here.
910
- */
911
639
  export function cancelConversation(conversationId) {
912
640
  const abort = active.get(conversationId);
913
641
  if (abort) {