@1presence/bridge 0.40.0 → 0.42.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,42 +1,41 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.SECTION_COLORS = void 0;
4
- exports.setVerbose = setVerbose;
5
- exports.setDebug = setDebug;
6
- exports.paint = paint;
7
- exports.spawnClaude = spawnClaude;
8
- exports.killAll = killAll;
9
- exports.cancelConversation = cancelConversation;
10
- const child_process_1 = require("child_process");
11
- const fs_1 = require("fs");
12
- const os_1 = require("os");
13
- const path_1 = require("path");
1
+ import { mkdirSync, writeFileSync, readFileSync } from 'fs';
2
+ import { tmpdir } from 'os';
3
+ import { join } from 'path';
4
+ import { query } from '@anthropic-ai/claude-agent-sdk';
5
+ // ─── Engine ────────────────────────────────────────────────────────────────────
6
+ //
7
+ // The bridge drives the local Claude Code install through the Claude Agent SDK's
8
+ // query() function — the same documented entrypoint Claude Code itself uses. It
9
+ // runs on the user's claude.ai subscription (Keychain OAuth, no API key), gives
10
+ // structured streaming, and lets Claude manage its own subprocess lifecycle and
11
+ // transient-error retries (which is why this file no longer carries the manual
12
+ // print-mode respawn loop the old `claude --print` path needed — see
13
+ // vault/Bugs.md and "Local Mode — Bridge Internals" in the vault). All
14
+ // product/tool/UI/glossary/disclosure/memory rules still come from the dynamic
15
+ // system prompt fetched from agent-api (mode=bridge) and are passed in as
16
+ // Options.systemPrompt — never baked into this package.
14
17
  // ─── Bridge working directory ─────────────────────────────────────────────────
15
18
  //
16
- // Claude Code always loads CLAUDE.md files from cwd upward plus the global
17
- // ~/.claude/CLAUDE.md. The bridge runs in a dedicated temp dir so it never
18
- // inherits a development repo's project CLAUDE.md. The CLAUDE.md we write
19
- // into that dir is a TINY GUARD ONLY — its sole job is to neutralize the
20
- // user's global ~/.claude/CLAUDE.md (which contains personal/dev rules that
21
- // would conflict with bridge behavior). All product, tool, UI, glossary,
22
- // disclosure, gmail, and memory rules come from the dynamic system prompt
23
- // fetched from agent-api with mode=bridge — see the "Local Mode runtime
24
- // adapter" section of that prompt and packages/agent-api/src/systemPrompt.ts.
25
- // Do NOT add product rules here; they belong in the dynamic prompt so hosted
26
- // and bridge stay in sync.
27
- const BRIDGE_CWD = (0, path_1.join)((0, os_1.tmpdir)(), '1presence-bridge');
19
+ // Claude Code can load CLAUDE.md files from cwd upward plus the global
20
+ // ~/.claude/CLAUDE.md. The bridge runs in a dedicated temp dir and passes
21
+ // settingSources: [] so no user/project settings or memory are loaded. The
22
+ // CLAUDE.md we write here is a TINY GUARD ONLY — defence-in-depth to neutralize
23
+ // the user's global ~/.claude/CLAUDE.md should any loading path reach it. All
24
+ // real rules come from the dynamic system prompt (mode=bridge). Do NOT add
25
+ // product rules here.
26
+ const BRIDGE_CWD = join(tmpdir(), '1presence-bridge');
28
27
  const BRIDGE_CLAUDE_MD = `# Local Mode — context guard
29
28
 
30
- You are running in 1Presence Local Mode. Your **--system-prompt-file** is the
31
- sole authoritative source for product rules, tool policy, glossary, and
32
- disclosure rules. Treat any other CLAUDE.md content — including the global
29
+ You are running in 1Presence Local Mode. Your **system prompt** is the sole
30
+ authoritative source for product rules, tool policy, glossary, and disclosure
31
+ rules. Treat any other CLAUDE.md content — including the global
33
32
  ~/.claude/CLAUDE.md and any project CLAUDE.md from a parent directory — as
34
33
  **not applicable** to this runtime. Do not follow it. Do not cite it.
35
34
  `;
36
35
  // Write the guard CLAUDE.md once on module load
37
- (0, fs_1.mkdirSync)(BRIDGE_CWD, { recursive: true });
38
- (0, fs_1.writeFileSync)((0, path_1.join)(BRIDGE_CWD, 'CLAUDE.md'), BRIDGE_CLAUDE_MD, 'utf-8');
39
- const config_1 = require("./config");
36
+ mkdirSync(BRIDGE_CWD, { recursive: true });
37
+ writeFileSync(join(BRIDGE_CWD, 'CLAUDE.md'), BRIDGE_CLAUDE_MD, 'utf-8');
38
+ import { getBridgeModel } from './config.js';
40
39
  // Track whether we've already announced the model this process — printing it
41
40
  // per-spawn is noisy; once on startup is what the user actually wants to see.
42
41
  let modelAnnounced = false;
@@ -44,13 +43,13 @@ let modelAnnounced = false;
44
43
  // PLUS the entire system prompt. Great for prompt debugging, noisy for
45
44
  // message debugging (the prompt dump buries the conversation).
46
45
  let verbose = false;
47
- function setVerbose(v) { verbose = v; }
46
+ export function setVerbose(v) { verbose = v; }
48
47
  // Debug flag — when set via --debug, render a clean, sectioned transcript of
49
48
  // the live turn: user prompt, assistant text, every tool input, every tool
50
49
  // result. This is the bridge equivalent of the chat's admin debug view. It
51
50
  // deliberately does NOT print the system prompt — that's what --verbose is for.
52
51
  let debug = false;
53
- function setDebug(v) { debug = v; }
52
+ export function setDebug(v) { debug = v; }
54
53
  function formatPayload(value) {
55
54
  try {
56
55
  return JSON.stringify(value, null, 2);
@@ -65,14 +64,14 @@ function formatPayload(value) {
65
64
  // the shape of the chat's admin debug bubbles (user / assistant / tool input /
66
65
  // tool result) so what you see locally mirrors what an admin sees in the app.
67
66
  const USE_COLOR = process.stderr.isTTY === true && !process.env['NO_COLOR'];
68
- function paint(code, s) {
67
+ export function paint(code, s) {
69
68
  return USE_COLOR ? `\x1b[${code}m${s}\x1b[0m` : s;
70
69
  }
71
70
  // ANSI colour codes per section, mirroring the admin debug palette. Shared
72
71
  // across all three console modes (debug / verbose / normal) so the same kind
73
72
  // of content is always the same colour — system prompts magenta, user prompts
74
73
  // blue, assistant text green, tool inputs cyan, tool results yellow.
75
- exports.SECTION_COLORS = {
74
+ export const SECTION_COLORS = {
76
75
  system: '35', // magenta
77
76
  user: '34', // blue
78
77
  assistant: '32', // green
@@ -108,79 +107,96 @@ function summariseHistoryBlock(block) {
108
107
  // can tell user turns from assistant turns at a glance — the missing
109
108
  // distinction that made replayed context unreadable in --debug.
110
109
  function renderHistoryMessage(msg) {
111
- const color = msg.role === 'user' ? exports.SECTION_COLORS.user : exports.SECTION_COLORS.assistant;
110
+ const color = msg.role === 'user' ? SECTION_COLORS.user : SECTION_COLORS.assistant;
112
111
  const body = typeof msg.content === 'string'
113
112
  ? msg.content
114
113
  : msg.content.map(summariseHistoryBlock).join('\n');
115
114
  debugBlock(`${msg.role} · history`, color, body);
116
115
  }
117
- // ─── Active processes ─────────────────────────────────────────────────────────
118
- const active = new Map();
119
- // conversationId → pending retry timer. A retry is scheduled with a backoff
120
- // delay, during which the conversation has NO entry in `active`. If a new user
121
- // message arrives in that window it must cancel the stale retry (otherwise the
122
- // retry would re-run the OLD turn's history and clobber the new one). The
123
- // supersede block clears any pending timer here before spawning.
124
- const pendingRetries = new Map();
125
- // Automatic retries when the `claude` CLI exits non-zero BEFORE producing any
126
- // real output. This covers the known Claude Code print-mode 400 regression that
127
- // surfaces as "API Error: 400 due to tool use concurrency issues" (GitHub
128
- // anthropics/claude-code#18131, still open) — it is non-deterministic enough
129
- // that a fresh spawn often succeeds. We retry ONLY when the failed attempt
130
- // produced no real assistant text and no tool calls, so a failure that lands
131
- // after real work (where retrying could double-execute a side-effectful tool)
132
- // is surfaced, never silently re-run.
116
+ // ─── Active turns ───────────────────────────────────────────────────────────────
133
117
  //
134
- // 2 retries = up to 3 attempts/turn. The first retry captures nearly all of the
135
- // transient wins; further attempts buy little on transient failures but add
136
- // latency and re-send the full (1M-context) history again on deterministic ones
137
- // see vault Bugs.md. Retries use escalating backoff (avoids a subscription
138
- // rate-limit cascade from rapid re-spawns) and stop once total retry time
139
- // exceeds the wall-clock cap, so a slow-failing attempt can't strand the user.
140
- // All below the SSE boundary, so the user sees only a slightly longer
141
- // "thinking" gap, never an intermediate error.
142
- const MAX_TURN_RETRIES = 2;
143
- const RETRY_BACKOFF_BASE_MS = 750; // delay = base * attempt# → 750ms, 1500ms
144
- const RETRY_WALL_CLOCK_CAP_MS = 12_000; // stop retrying past this much elapsed
145
- // Map a non-zero CLI exit + any captured "API Error:" line to a concise,
118
+ // conversationId AbortController for the in-flight query(). Aborting cancels
119
+ // the turn (supersede on a new message, or the Stop button via the gateway's
120
+ // `cancel` frame). The SDK's query() loop ends when its controller aborts.
121
+ const active = new Map();
122
+ // Map a thrown query() error / captured "API Error:" text to a concise,
146
123
  // user-facing Local Mode message. The raw upstream text stays in operator logs
147
124
  // only — we never echo a wall of provider error JSON into the chat. Referring
148
125
  // to "Claude Code" here is intentional and consistent with Local Mode's other
149
- // operational errors (e.g. the "claude CLI not found" message): in Local Mode
150
- // the user is knowingly running their own Claude Code install.
151
- //
152
- // NOTE on the 400 tool-use case: this is an open Claude Code print-mode
153
- // regression (introduced in 2.1.19, still present in 2.1.146 — the current
154
- // latest), so upgrading does NOT fix it. We deliberately do not suggest an
155
- // upgrade; the automatic retry is the real mitigation and resending sometimes
156
- // gets through.
157
- function describeCliFailure(code, apiErrorText, authFailure) {
126
+ // operational errors: in Local Mode the user is knowingly running their own
127
+ // Claude Code install.
128
+ function describeCliFailure(apiErrorText, authFailure) {
158
129
  const t = apiErrorText.trim();
159
130
  // Auth/credential failure (401/403). Local Mode runs the user's own Claude
160
- // Code, so naming it (and /login) is intentional and consistent with the
161
- // "claude CLI not found" message this is the only place that can tell them
162
- // how to recover. Takes precedence over the generic branches below.
131
+ // Code, so naming it (and /login) is intentional this is the only place
132
+ // that can tell them how to recover. Takes precedence over generic branches.
163
133
  if (authFailure) {
164
134
  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.';
165
135
  }
166
- if (/API Error:\s*400/i.test(t) && /(tool use|concurren|parallel)/i.test(t)) {
167
- 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';
168
- }
169
136
  if (/^API Error:/i.test(t)) {
170
137
  return `Local Mode error from Claude Code: ${t.replace(/^API Error:\s*/i, '').trim()}`;
171
138
  }
172
- return `Local Mode stopped unexpectedly (claude exited with code ${code ?? 'unknown'}). Please try again.`;
139
+ if (t) {
140
+ return `Local Mode stopped unexpectedly: ${t}`;
141
+ }
142
+ return 'Local Mode stopped unexpectedly. Please try again.';
143
+ }
144
+ // ─── Prompt construction ─────────────────────────────────────────────────────────
145
+ //
146
+ // The gateway pushes the FULL conversation (sanitised via @presence/shared
147
+ // toModelMessages) and `history` already ends with the new user turn. The SDK's
148
+ // streaming input only triggers an assistant turn for user messages whose
149
+ // `shouldQuery` is not false — so we replay every PRIOR turn with
150
+ // shouldQuery:false (appended to the transcript, no turn generated), inject
151
+ // assistant turns verbatim (carrying their tool_use blocks), and let ONLY the
152
+ // final/live user turn run. This preserves stateless structured replay: no
153
+ // session resume, no flat-text collapse, no local jsonl — Firestore stays the
154
+ // single source of truth, exactly as the CLI stdin replay did.
155
+ function buildPromptMessages(history) {
156
+ // Index of the live user turn — the last user-role message. The gateway
157
+ // always appends it; the scan is defensive against an unexpected tail.
158
+ let liveIdx = -1;
159
+ for (let i = history.length - 1; i >= 0; i--) {
160
+ if (history[i].role === 'user') {
161
+ liveIdx = i;
162
+ break;
163
+ }
164
+ }
165
+ const out = [];
166
+ history.forEach((msg, i) => {
167
+ // Normalise to array-of-blocks (a bare string becomes a single text block).
168
+ const content = Array.isArray(msg.content)
169
+ ? msg.content
170
+ : [{ type: 'text', text: typeof msg.content === 'string' ? msg.content : '' }];
171
+ if (msg.role === 'assistant') {
172
+ // Injected verbatim — runtime accepts a {type:'assistant'} message on the
173
+ // input stream and appends it to the transcript (it never triggers a turn).
174
+ out.push({ type: 'assistant', message: { role: 'assistant', content }, parent_tool_use_id: null });
175
+ }
176
+ else {
177
+ const isLive = i === liveIdx;
178
+ out.push({
179
+ type: 'user',
180
+ ...(isLive ? {} : { shouldQuery: false }),
181
+ message: { role: 'user', content },
182
+ parent_tool_use_id: null,
183
+ });
184
+ }
185
+ });
186
+ return out;
173
187
  }
174
- // ─── Spawn ────────────────────────────────────────────────────────────────────
175
- function spawnClaude(params) {
188
+ async function* promptStream(messages) {
189
+ for (const m of messages)
190
+ yield m;
191
+ }
192
+ // ─── Spawn (drive one turn through the SDK) ──────────────────────────────────────
193
+ export function spawnClaude(params) {
176
194
  const { conversationId, presenceSessionId, text, uid, history, vaultFileOpen, clientCapabilities, syncedFolders, onEvent, onDone, onError, onNotice } = params;
177
- const attemptIdx = params._attemptIdx ?? 0;
178
- const firstAttemptAt = params._firstAttemptAt ?? Date.now();
179
- const systemPromptPath = (0, path_1.join)((0, os_1.tmpdir)(), `agent-${uid}.md`);
180
- const mcpConfigPath = (0, path_1.join)((0, os_1.tmpdir)(), `mcp-${uid}.json`);
195
+ const systemPromptPath = join(tmpdir(), `agent-${uid}.md`);
196
+ const mcpConfigPath = join(tmpdir(), `mcp-${uid}.json`);
181
197
  if (verbose) {
182
198
  process.stderr.write(paint('90', `[bridge:verbose] cwd: ${BRIDGE_CWD}`) + '\n');
183
- process.stderr.write(paint('90', `[bridge:verbose] override md: ${(0, path_1.join)(BRIDGE_CWD, 'CLAUDE.md')}`) + '\n');
199
+ process.stderr.write(paint('90', `[bridge:verbose] override md: ${join(BRIDGE_CWD, 'CLAUDE.md')}`) + '\n');
184
200
  process.stderr.write(paint('90', `[bridge:verbose] system prompt: ${systemPromptPath}`) + '\n');
185
201
  process.stderr.write(paint('90', `[bridge:verbose] mcp config: ${mcpConfigPath}`) + '\n');
186
202
  process.stderr.write(paint('90', `[bridge:verbose] session id: ${presenceSessionId}`) + '\n');
@@ -189,22 +205,12 @@ function spawnClaude(params) {
189
205
  }
190
206
  // Surface the user's UID before the session line in every mode — it's the
191
207
  // Firestore doc prefix (`sessions/<uid>_<conversationId>`), so logging it
192
- // makes a reported bridge failure correlatable to the stored session without
193
- // having to ask which account hit it. The CLI's own `--session-id` is
194
- // ephemeral and is NOT the Firestore conversationId, so the uid is the key
195
- // join column when debugging.
208
+ // makes a reported bridge failure correlatable to the stored session.
196
209
  process.stderr.write(`[bridge] user ${uid}\n`);
197
- // Debug transcript: lead with the user prompt for this turn (the clean
198
- // message, before the gateway's ephemeral-context prefix), plus the session
199
- // id (correlates with the chat URL / Firestore session doc) and a hint at
200
- // how much prior context is being replayed.
210
+ // Debug transcript: lead with the prior context (replayed history) then the
211
+ // live user prompt. `history` already ends with the new user turn.
201
212
  if (debug || verbose) {
202
213
  process.stderr.write(`\n${paint('1', `══ session ${presenceSessionId} ══`)}\n`);
203
- // `history` already ends with the new user prompt (gateway-appended). Render
204
- // every PRIOR message with its role colour so what the model saw as context
205
- // is auditable — then the live turn is shown via the clean `text` below and
206
- // streams in beneath it. Defensive: if the tail isn't the current user turn
207
- // (unexpected), render the whole history rather than dropping a message.
208
214
  const tail = history[history.length - 1];
209
215
  const prior = tail?.role === 'user' ? history.slice(0, -1) : history;
210
216
  if (prior.length > 0) {
@@ -212,456 +218,366 @@ function spawnClaude(params) {
212
218
  for (const msg of prior)
213
219
  renderHistoryMessage(msg);
214
220
  }
215
- debugBlock('user · this turn', exports.SECTION_COLORS.user, text);
221
+ debugBlock('user · this turn', SECTION_COLORS.user, text);
216
222
  }
217
223
  else {
218
- // Default mode is quiet, but always surface the session id once per turn so
219
- // it can be matched to the chat URL / Firestore session doc when debugging.
220
224
  process.stderr.write(`[bridge] session ${presenceSessionId}\n`);
221
225
  }
222
- // tool_use_id tool name, so a tool_result block (which only carries the id)
223
- // can be labelled with the tool it answers in the debug transcript.
224
- const toolNames = new Map();
225
- // If a prior process is still running for this conversation (user sent a
226
- // follow-up before the previous turn finished), supersede it. The latest
227
- // user intent wins; the orphan would otherwise keep streaming events.
228
- const existing = active.get(conversationId);
229
- if (existing) {
230
- process.stderr.write(`[bridge] superseding active conversation ${conversationId}\n`);
231
- existing.kill('SIGTERM');
232
- active.delete(conversationId);
233
- }
234
- // Cancel any retry scheduled for this conversation that hasn't fired yet.
235
- // Without this, a new user message arriving during a retry's backoff window
236
- // would race the stale retry — which carries the OLD turn's history and would
237
- // clobber the new turn. Skip when this call IS the retry firing (attemptIdx>0,
238
- // the timer already deleted itself before invoking us).
239
- if (attemptIdx === 0) {
240
- const pending = pendingRetries.get(conversationId);
241
- if (pending) {
242
- clearTimeout(pending);
243
- pendingRetries.delete(conversationId);
244
- process.stderr.write(`[bridge] cancelled pending retry for ${conversationId} (superseded by new turn)\n`);
245
- }
246
- }
247
- // Note: ephemeral context (vault_file_open / client_capabilities / synced_folders)
248
- // is injected into the last user message by the gateway BEFORE history is
249
- // sent over the WS. The bridge no longer constructs `userMessageText` —
250
- // `history` is the authoritative stream and already contains the new user
251
- // prompt with prefix prepended. The `text`, `vaultFileOpen`,
252
- // `clientCapabilities`, `syncedFolders` SpawnParams are retained for
253
- // backward-compatible logging / spool correlation only.
226
+ // ephemeral context (vault_file_open / client_capabilities / synced_folders) is
227
+ // injected into the last user message by the gateway BEFORE history is sent
228
+ // these params are retained for backward-compatible logging only.
254
229
  void vaultFileOpen;
255
230
  void clientCapabilities;
256
231
  void syncedFolders;
257
232
  void text;
258
- // Lockdown rationale:
259
- // - `--tools ""` disables ALL built-in tools (Bash/Read/Write/Edit/Glob/Grep/
260
- // WebFetch/etc.). MCP tools are not "built-in" so the 1Presence MCP surface
261
- // remains available.
262
- // - `--setting-sources ""` prevents claude CLI from loading the user's
263
- // ~/.claude/settings.json (and project/.local equivalents). Without this,
264
- // permissive `permissions.allow` rules in the user's personal Claude Code
265
- // config would silently re-enable Bash/Edit/Write etc. inside the bridge.
266
- // - `--strict-mcp-config` keeps the MCP surface to exactly what we wire in
267
- // via --mcp-config. Together these guarantee the bridge can only call
268
- // `mcp__1presence__*` — no filesystem, no shell, no arbitrary network.
269
- //
270
- // Session continuity rationale:
271
- // - `--input-format stream-json` accepts structured user/assistant messages
272
- // on stdin. We replay prior turns (loaded by the gateway from Firestore)
273
- // followed by the new user turn — this is how the bridge sees history.
274
- // - `--no-session-persistence` keeps no jsonl on disk. The bridge has zero
275
- // local filesystem dependency for continuity; Firestore is the only
276
- // source of truth.
277
- // - `--session-id <uuid>` must be a fresh UUID per spawn: the CLI treats
278
- // this flag as a "claim a new session ID" operation and rejects the
279
- // second spawn with "Session ID X is already in use" if we reuse one
280
- // across turns of a chat — even with --no-session-persistence. The
281
- // bridge passes the per-spawn `conversationId` here; the presence
282
- // sessionId is correlated separately via bridge logs and spool records.
283
- // The CLI treats --session-id as "claim this new session ID" and rejects a
284
- // reused id with "Session ID X is already in use". A retry is a fresh spawn,
285
- // so it MUST use a new uuid; the first attempt keeps the correlation id.
286
- const spawnSessionId = attemptIdx === 0 ? presenceSessionId : crypto.randomUUID();
287
- const args = [
288
- '--print',
289
- '--input-format', 'stream-json',
290
- '--output-format', 'stream-json',
291
- '--verbose',
292
- '--tools', '',
293
- '--setting-sources', '',
294
- '--allowedTools', 'mcp__1presence__*',
295
- '--system-prompt-file', systemPromptPath,
296
- '--mcp-config', mcpConfigPath,
297
- '--strict-mcp-config',
298
- '--no-session-persistence',
299
- '--session-id', spawnSessionId,
300
- ];
301
- const pinnedModel = (0, config_1.getBridgeModel)();
302
- if (pinnedModel) {
303
- args.push('--model', pinnedModel);
304
- }
305
- // Strip API key so Claude Code uses the user's claude.ai Pro subscription
306
- // (OAuth credentials), not an API key that would bill to a separate account.
307
- const { ANTHROPIC_API_KEY: _stripped, ...safeEnv } = process.env;
308
- const proc = (0, child_process_1.spawn)('claude', args, {
309
- cwd: BRIDGE_CWD,
310
- env: safeEnv,
311
- stdio: ['pipe', 'pipe', 'pipe'],
312
- });
313
- active.set(conversationId, proc);
314
- // Feed the full conversation via stdin as stream-json. The gateway's
315
- // early-save committed the new user message to Firestore BEFORE building
316
- // `history`, so `history` already ends with the new user prompt (with the
317
- // ephemeral context prefix prepended by the gateway). The bridge no longer
318
- // appends a separate `newTurn` — doing so would duplicate the user prompt.
319
- // Sanitisation (orphan tool_use stripping, displayOnly filtering, consecutive
320
- // same-role merging) already happened on the gateway via
321
- // @presence/shared.toModelMessages — replay the history verbatim.
322
- try {
323
- const stdin = proc.stdin;
324
- if (!stdin) {
325
- throw new Error('claude stdin is null — spawn must use stdio[0]="pipe"');
326
- }
327
- for (const msg of history) {
328
- // Normalise to array-of-blocks: Claude Code's stream-json input parser
329
- // iterates `content` directly. A string slips into a `"tool_use_id" in
330
- // <char>` check inside the CLI and aborts the process with `W is not an
331
- // Object` (JSC) / exit 1 mid-turn. The gateway also normalises before
332
- // sending, so a current gateway + any bridge version is safe; this guard
333
- // covers older gateways and ad-hoc local replay tests.
334
- const content = Array.isArray(msg.content)
335
- ? msg.content
336
- : [{ type: 'text', text: typeof msg.content === 'string' ? msg.content : '' }];
337
- const wrapped = { type: msg.role, message: { role: msg.role, content } };
338
- stdin.write(JSON.stringify(wrapped) + '\n');
339
- }
340
- stdin.end();
341
- }
342
- catch (err) {
343
- process.stderr.write(`[bridge] failed to write stdin: ${err.message}\n`);
344
- proc.kill('SIGTERM');
345
- onError(`stdin write failed: ${err.message}`, null, null);
346
- return;
233
+ // Supersede any in-flight turn for this conversation (user sent a follow-up
234
+ // before the previous turn finished). The latest intent wins.
235
+ const existing = active.get(conversationId);
236
+ if (existing) {
237
+ process.stderr.write(`[bridge] superseding active conversation ${conversationId}\n`);
238
+ existing.abort();
239
+ active.delete(conversationId);
347
240
  }
241
+ const abort = new AbortController();
242
+ active.set(conversationId, abort);
243
+ // tool_use_id → tool name, so a tool_result block (which only carries the id)
244
+ // can be labelled with the tool it answers in the debug transcript.
245
+ const toolNames = new Map();
246
+ // Per-turn accounting.
348
247
  let sessionIdExtracted = false;
349
248
  let messageCount = 0;
350
249
  let costUsd = 0;
351
250
  let usage = null;
352
251
  // Prompt size of the MOST RECENT assistant call (input + both cache buckets),
353
252
  // overwritten on each assistant event so it ends on the turn's final, fullest
354
- // call. This — not the summed `usage` above — is the current context fill the
355
- // status line's 🧠 segment reports against the model's window.
253
+ // call. This — not the summed `usage` — is the current context fill the status
254
+ // line's 🧠 segment reports against the model's window.
356
255
  let lastContextTokens = 0;
357
256
  let extractedModel = null;
358
- let buffer = '';
359
257
  let killedForViolation = false;
360
- // Retry/error-surfacing tracking for this attempt:
361
- // - sawApiError: the CLI emitted an "API Error:" assistant text event (the
362
- // way Claude Code reports an underlying API failure mid-turn).
363
- // - apiErrorText: that text, captured for describeCliFailure().
364
- // - producedRealOutput: any real assistant text or tool_use was emitted, so
365
- // a later failure must NOT be retried (could double-run a side-effect).
366
258
  let sawApiError = false;
367
- // - sawAuthFailure: a 401/403 auth/credential failure (the user's local
368
- // Claude Code is not signed in). Surfaced as an actionable message and
369
- // never retried (re-spawning won't add credentials).
370
259
  let sawAuthFailure = false;
371
260
  let apiErrorText = '';
372
261
  let producedRealOutput = false;
373
- proc.stdout.on('data', (chunk) => {
374
- buffer += chunk.toString('utf-8');
375
- const lines = buffer.split('\n');
376
- buffer = lines.pop() ?? '';
377
- for (const line of lines) {
378
- const trimmed = line.trim();
379
- if (!trimmed)
380
- continue;
381
- let event;
382
- try {
383
- event = JSON.parse(trimmed);
262
+ // Allow only the 1Presence MCP surface to execute. Built-in tools are disabled
263
+ // via extraArgs `--tools ""`; this is the runtime safety net (a hard deny that
264
+ // runs before any execution) for anything that slips past. Our MCP tools are
265
+ // auto-approved via allowedTools, so this callback only ever fires to deny.
266
+ const canUseTool = async (toolName, input) => {
267
+ if (toolName.startsWith('mcp__1presence__'))
268
+ return { behavior: 'allow', updatedInput: input };
269
+ return { behavior: 'deny', message: `Tool ${toolName} is not allowed in Local Mode`, interrupt: true };
270
+ };
271
+ // Strip API key so Claude Code uses the user's claude.ai subscription (OAuth
272
+ // credentials in the Keychain), not an API key that would bill a separate
273
+ // account. Options.env REPLACES the subprocess env, so spread the rest through.
274
+ const { ANTHROPIC_API_KEY: _stripped, ...safeEnv } = process.env;
275
+ const pinnedModel = getBridgeModel();
276
+ // Process one translated raw stream-json event: bookkeeping + forward. Mirrors
277
+ // the old CLI stdout parser so the gateway/accumulator see identical shapes.
278
+ // Returns false when the event must be suppressed (errors) or the turn was
279
+ // killed for a tool violation.
280
+ function handleEvent(event) {
281
+ const type = event['type'];
282
+ if (!sessionIdExtracted && type === 'system' && event['subtype'] === 'init') {
283
+ const keySource = event['apiKeySource'];
284
+ const model = event['model'];
285
+ if (model)
286
+ extractedModel = model;
287
+ if (!modelAnnounced) {
288
+ const source = keySource === 'none' || !keySource ? 'claude.ai subscription' : keySource;
289
+ const pin = getBridgeModel() ? ' (selected at startup)' : '';
290
+ process.stdout.write(`\n model: ${model ?? 'unknown'}${pin}\n auth: ${source}\n\n`);
291
+ modelAnnounced = true;
384
292
  }
385
- catch {
386
- continue;
293
+ else {
294
+ process.stderr.write(`[bridge] model: ${model ?? 'unknown'} apiKeySource: ${keySource ?? 'none'}\n`);
387
295
  }
388
- const type = event['type'];
389
- // Set when this event is the CLI's "API Error:" turn — we neither forward
390
- // it to the PWA nor let it reach the accumulator (it carries no real
391
- // content and would poison history / show a raw error mid-stream).
392
- let suppressEvent = false;
393
- // Extract model + key source info from the first system/init event.
394
- // No session-id persistence — Firestore is the only source of truth
395
- // now, and we pin --session-id to presenceSessionId on every spawn.
396
- if (!sessionIdExtracted && type === 'system' && event['subtype'] === 'init') {
397
- const keySource = event['apiKeySource'];
398
- const model = event['model'];
399
- if (model)
400
- extractedModel = model;
401
- if (!modelAnnounced) {
402
- // First conversation since bridge started — announce prominently
403
- // so the user can confirm which model and credential is in use.
404
- const source = keySource === 'none' || !keySource ? 'claude.ai subscription' : keySource;
405
- const pin = (0, config_1.getBridgeModel)() ? ' (selected at startup)' : '';
406
- process.stdout.write(`\n model: ${model ?? 'unknown'}${pin}\n auth: ${source}\n\n`);
407
- modelAnnounced = true;
408
- }
409
- else {
410
- // Subsequent conversations — quiet line for power users.
411
- process.stderr.write(`[bridge] model: ${model ?? 'unknown'} apiKeySource: ${keySource ?? 'none'}\n`);
412
- }
413
- sessionIdExtracted = true;
296
+ sessionIdExtracted = true;
297
+ }
298
+ if (type === 'assistant') {
299
+ messageCount++;
300
+ const msg = event['message'];
301
+ const u = msg?.['usage'];
302
+ if (u) {
303
+ usage = {
304
+ input_tokens: (usage?.input_tokens ?? 0) + (u['input_tokens'] ?? 0),
305
+ output_tokens: (usage?.output_tokens ?? 0) + (u['output_tokens'] ?? 0),
306
+ cache_read_input_tokens: (usage?.cache_read_input_tokens ?? 0) + (u['cache_read_input_tokens'] ?? 0),
307
+ cache_creation_input_tokens: (usage?.cache_creation_input_tokens ?? 0) + (u['cache_creation_input_tokens'] ?? 0),
308
+ };
309
+ lastContextTokens = (u['input_tokens'] ?? 0) + (u['cache_read_input_tokens'] ?? 0) + (u['cache_creation_input_tokens'] ?? 0);
414
310
  }
415
- // Count complete assistant turns + accumulate token usage + log tool calls
416
- if (type === 'assistant') {
417
- messageCount++;
418
- const msg = event['message'];
419
- const u = msg?.['usage'];
420
- if (u) {
421
- usage = {
422
- input_tokens: (usage?.input_tokens ?? 0) + (u['input_tokens'] ?? 0),
423
- output_tokens: (usage?.output_tokens ?? 0) + (u['output_tokens'] ?? 0),
424
- cache_read_input_tokens: (usage?.cache_read_input_tokens ?? 0) + (u['cache_read_input_tokens'] ?? 0),
425
- cache_creation_input_tokens: (usage?.cache_creation_input_tokens ?? 0) + (u['cache_creation_input_tokens'] ?? 0),
426
- };
427
- // Full prompt size of THIS assistant call — non-cached input plus both
428
- // cache buckets. Overwrite (don't sum): the last write wins, which is
429
- // the turn's largest/final context.
430
- lastContextTokens = (u['input_tokens'] ?? 0) + (u['cache_read_input_tokens'] ?? 0) + (u['cache_creation_input_tokens'] ?? 0);
431
- }
432
- const content = msg?.['content'];
433
- if (Array.isArray(content)) {
434
- let wroteText = false;
435
- for (const block of content) {
436
- if (block['type'] === 'tool_use') {
437
- producedRealOutput = true;
438
- const toolName = block['name'];
439
- const toolId = block['id'];
440
- if (toolId)
441
- toolNames.set(toolId, toolName);
442
- if (debug) {
443
- // Clean transcript: a single coloured block with the full input.
444
- debugBlock(`tool → ${toolName}`, exports.SECTION_COLORS.input, formatPayload(block['input']));
445
- }
446
- else {
447
- if (wroteText) {
448
- process.stderr.write('\n');
449
- wroteText = false;
450
- }
451
- const prefix = toolName.startsWith('mcp__') ? '[mcp]' : '[tool]';
452
- process.stderr.write(paint(exports.SECTION_COLORS.input, `[bridge] ${prefix} ${toolName}`) + '\n');
453
- if (verbose) {
454
- const input = block['input'];
455
- process.stderr.write(paint(exports.SECTION_COLORS.input, `[bridge:verbose] ─── input ${toolName} ───\n${formatPayload(input)}\n[bridge:verbose] ─── end input ───`) + '\n');
456
- }
311
+ const content = msg?.['content'];
312
+ if (Array.isArray(content)) {
313
+ let wroteText = false;
314
+ for (const block of content) {
315
+ if (block['type'] === 'tool_use') {
316
+ producedRealOutput = true;
317
+ const toolName = block['name'];
318
+ const toolId = block['id'];
319
+ if (toolId)
320
+ toolNames.set(toolId, toolName);
321
+ if (debug) {
322
+ debugBlock(`tool → ${toolName}`, SECTION_COLORS.input, formatPayload(block['input']));
323
+ }
324
+ else {
325
+ if (wroteText) {
326
+ process.stderr.write('\n');
327
+ wroteText = false;
457
328
  }
458
- // Defense-in-depth: CLI flags (--tools "", --allowedTools, --strict-mcp-config,
459
- // --setting-sources "") are supposed to make this unreachable. If we see a
460
- // non-1Presence tool here anyway, something has bypassed those guards — kill
461
- // immediately so any side effect already in flight is the only damage done.
462
- //
463
- // Valid forms:
464
- // mcp__1presence__<name> — namespaced MCP form
465
- // <snake_case_name> — bare form; Claude Code may omit the prefix in
466
- // stream-json output. Safe because --strict-mcp-config
467
- // limits MCP to the 1presence server only.
468
- // Invalid (real violations):
469
- // PascalCase (Bash, Read, Write, …) — Claude Code built-ins
470
- // mcp__<other>__* — tools from a different MCP server
471
- const isMcp1presence = toolName.startsWith('mcp__1presence__');
472
- const isBareName = /^[a-z][a-z0-9_]*$/.test(toolName);
473
- if (!isMcp1presence && !isBareName) {
474
- killedForViolation = true;
475
- const violation = `bridge tool violation: ${toolName} is not allowed in Local Mode`;
476
- process.stderr.write(`[bridge] FATAL ${violation} — killing\n`);
477
- active.delete(conversationId);
478
- proc.kill('SIGKILL');
479
- onError(violation, usage, extractedModel);
480
- return;
329
+ const prefix = toolName.startsWith('mcp__') ? '[mcp]' : '[tool]';
330
+ process.stderr.write(paint(SECTION_COLORS.input, `[bridge] ${prefix} ${toolName}`) + '\n');
331
+ if (verbose) {
332
+ process.stderr.write(paint(SECTION_COLORS.input, `[bridge:verbose] ─── input ${toolName} ───\n${formatPayload(block['input'])}\n[bridge:verbose] ─── end input ───`) + '\n');
481
333
  }
482
334
  }
483
- else if (block['type'] === 'text') {
484
- const text = block['text'];
485
- if (text) {
486
- // The CLI reports auth/credential failures (401/403) as a
487
- // <synthetic> assistant text turn whose wording varies and does
488
- // NOT reliably start with "API Error:" — e.g. "Please run /login
489
- // · API Error: 401 Invalid authentication credentials" or
490
- // "Failed to authenticate. API Error: 401 …". Detect by the
491
- // structured signal (the event's `error: authentication_failed`
492
- // / `model: <synthetic>`) plus a wording fallback, so it is
493
- // classified instead of leaking raw into the chat as if the
494
- // model had said it.
495
- const isSynthetic = msg?.['model'] === '<synthetic>';
496
- const isAuthFailure = event['error'] === 'authentication_failed' ||
497
- (isSynthetic && /(api error:\s*40[13]\b|invalid (api key|authentication)|please run \/login|failed to authenticate|unauthor)/i.test(text));
498
- if (/^API Error:/i.test(text.trimStart()) || isAuthFailure) {
499
- // The CLI is reporting an underlying API/auth failure as
500
- // assistant text. Capture it for the user-facing message, and
501
- // suppress the whole event so the raw error never reaches the
502
- // PWA or the accumulator (the gateway also blanks it via
503
- // cleanTurnText — this is the upstream defense).
504
- sawApiError = true;
505
- apiErrorText = text.trim();
506
- if (isAuthFailure)
507
- sawAuthFailure = true;
508
- // Operator log keeps the raw provider line verbatim (with a
509
- // [bridge] prefix) so the real reason is diagnosable locally.
510
- suppressEvent = true;
511
- process.stderr.write(paint(exports.SECTION_COLORS.result, `[bridge] ${text.replace(/\n+/g, ' ')}`) + '\n');
512
- }
513
- else {
514
- producedRealOutput = true;
515
- if (debug) {
516
- // Full text, newlines intact — the readable transcript.
517
- debugBlock('assistant', exports.SECTION_COLORS.assistant, text);
518
- }
519
- else {
520
- process.stderr.write(paint(exports.SECTION_COLORS.assistant, text.replace(/\n+/g, ' ')));
521
- wroteText = true;
522
- }
523
- }
524
- }
335
+ // Defense-in-depth: canUseTool + --tools "" + strictMcpConfig should
336
+ // make a non-1Presence tool unreachable. If one appears anyway, kill
337
+ // the turn so any side effect in flight is the only damage done.
338
+ const isMcp1presence = toolName.startsWith('mcp__1presence__');
339
+ const isBareName = /^[a-z][a-z0-9_]*$/.test(toolName);
340
+ if (!isMcp1presence && !isBareName) {
341
+ killedForViolation = true;
342
+ const violation = `bridge tool violation: ${toolName} is not allowed in Local Mode`;
343
+ process.stderr.write(`[bridge] FATAL ${violation} aborting\n`);
344
+ active.delete(conversationId);
345
+ abort.abort();
346
+ onError(violation, usage, extractedModel);
347
+ return false;
525
348
  }
526
349
  }
527
- if (wroteText)
528
- process.stderr.write('\n');
529
- }
530
- }
531
- // Tool results stream back as `user` events with tool_result blocks.
532
- if ((verbose || debug) && type === 'user') {
533
- const msg = event['message'];
534
- const content = msg?.['content'];
535
- if (Array.isArray(content)) {
536
- for (const block of content) {
537
- if (block['type'] === 'tool_result') {
538
- const id = block['tool_use_id'] ?? '';
539
- const out = block['content'];
350
+ else if (block['type'] === 'text') {
351
+ const blockText = block['text'];
352
+ if (blockText) {
353
+ // The CLI/SDK can report auth/API failures as a synthetic assistant
354
+ // text turn whose wording varies. Detect by the structured signal
355
+ // (event.error) plus a wording fallback, so it's classified rather
356
+ // than leaking raw into the chat as if the model had said it.
357
+ const isSynthetic = msg?.['model'] === '<synthetic>';
358
+ const isAuthFailure = event['error'] === 'authentication_failed' ||
359
+ (isSynthetic && /(api error:\s*40[13]\b|invalid (api key|authentication)|please run \/login|failed to authenticate|unauthor)/i.test(blockText));
360
+ if (/^API Error:/i.test(blockText.trimStart()) || isAuthFailure) {
361
+ sawApiError = true;
362
+ apiErrorText = blockText.trim();
363
+ if (isAuthFailure)
364
+ sawAuthFailure = true;
365
+ process.stderr.write(paint(SECTION_COLORS.result, `[bridge] ${blockText.replace(/\n+/g, ' ')}`) + '\n');
366
+ return false; // suppress — never forward a raw error turn
367
+ }
368
+ producedRealOutput = true;
540
369
  if (debug) {
541
- const name = toolNames.get(id) ?? id ?? 'result';
542
- const errFlag = block['is_error'] ? ' [error]' : '';
543
- debugBlock(`result ← ${name}${errFlag}`, exports.SECTION_COLORS.result, formatPayload(out));
370
+ debugBlock('assistant', SECTION_COLORS.assistant, blockText);
544
371
  }
545
372
  else {
546
- process.stderr.write(paint(exports.SECTION_COLORS.result, `[bridge:verbose] ─── output ${id} ───\n${formatPayload(out)}\n[bridge:verbose] ─── end output ───`) + '\n');
373
+ process.stderr.write(paint(SECTION_COLORS.assistant, blockText.replace(/\n+/g, ' ')));
374
+ wroteText = true;
547
375
  }
548
376
  }
549
377
  }
550
378
  }
379
+ if (wroteText)
380
+ process.stderr.write('\n');
551
381
  }
552
- // Extract cost from the final result event. The CLI also stamps auth/API
553
- // failures here as `is_error` + `api_error_status` (even though `subtype`
554
- // stays "success"), so treat it as a robust backstop in case the
555
- // assistant-text signal above was missed (wording drift across CLI
556
- // versions). 401/403 → auth failure; other statuses keep the existing
557
- // 400-retry behaviour (sawApiError only).
558
- if (type === 'result') {
559
- const c = event['cost_usd'] ?? event['total_cost_usd'];
560
- if (typeof c === 'number')
561
- costUsd = c;
562
- if (event['is_error'] === true) {
563
- sawApiError = true;
564
- const status = event['api_error_status'];
565
- if (status === 401 || status === 403)
566
- sawAuthFailure = true;
567
- if (!apiErrorText && typeof event['result'] === 'string') {
568
- apiErrorText = event['result'].trim();
382
+ }
383
+ if ((verbose || debug) && type === 'user') {
384
+ const msg = event['message'];
385
+ const content = msg?.['content'];
386
+ if (Array.isArray(content)) {
387
+ for (const block of content) {
388
+ if (block['type'] === 'tool_result') {
389
+ const id = block['tool_use_id'] ?? '';
390
+ const out = block['content'];
391
+ if (debug) {
392
+ const name = toolNames.get(id) ?? id ?? 'result';
393
+ const errFlag = block['is_error'] ? ' [error]' : '';
394
+ debugBlock(`result ${name}${errFlag}`, SECTION_COLORS.result, formatPayload(out));
395
+ }
396
+ else {
397
+ process.stderr.write(paint(SECTION_COLORS.result, `[bridge:verbose] ─── output ${id} ───\n${formatPayload(out)}\n[bridge:verbose] ─── end output ───`) + '\n');
398
+ }
569
399
  }
570
400
  }
571
401
  }
572
- if (!suppressEvent)
573
- onEvent(event);
574
402
  }
575
- });
576
- proc.stderr.on('data', (chunk) => {
577
- // claude CLI writes logs to stderr not errors, just noise
578
- const line = chunk.toString('utf-8').trim();
579
- if (line)
580
- process.stderr.write(`[claude] ${line}\n`);
581
- });
582
- proc.on('close', (code) => {
583
- active.delete(conversationId);
584
- // Violation path already called onError + killed — don't double-fire.
585
- if (killedForViolation)
403
+ if (type === 'result') {
404
+ // total_cost_usd is the SDK's notional figure (0 on the no-op history
405
+ // append cycle; the real number on the live turn). Keep the largest seen.
406
+ const c = event['total_cost_usd'] ?? event['cost_usd'];
407
+ if (typeof c === 'number' && c > costUsd)
408
+ costUsd = c;
409
+ if (event['is_error'] === true) {
410
+ sawApiError = true;
411
+ const status = event['api_error_status'];
412
+ if (status === 401 || status === 403)
413
+ sawAuthFailure = true;
414
+ if (!apiErrorText && typeof event['result'] === 'string') {
415
+ apiErrorText = event['result'].trim();
416
+ }
417
+ }
418
+ }
419
+ return true;
420
+ }
421
+ // Drive the turn. Synchronous spawnClaude returns immediately; the SDK loop
422
+ // runs in this async IIFE and fires the same callbacks the CLI path did.
423
+ void (async () => {
424
+ let systemPrompt;
425
+ let mcpServers;
426
+ try {
427
+ systemPrompt = readFileSync(systemPromptPath, 'utf-8');
428
+ const mcpRaw = JSON.parse(readFileSync(mcpConfigPath, 'utf-8'));
429
+ mcpServers = mcpRaw.mcpServers ?? {};
430
+ }
431
+ catch (err) {
432
+ active.delete(conversationId);
433
+ onError(`Local Mode setup files unavailable: ${err.message}`, null, null);
586
434
  return;
587
- // Flush any remaining buffer
588
- if (buffer.trim()) {
589
- try {
590
- onEvent(JSON.parse(buffer.trim()));
435
+ }
436
+ const options = {
437
+ systemPrompt, // custom string → replaces the default Claude Code prompt
438
+ mcpServers: mcpServers,
439
+ strictMcpConfig: true, // only our MCP server, ignore project/user/plugin MCP
440
+ settingSources: [], // no user/project settings or memory
441
+ allowedTools: ['mcp__1presence__*'], // auto-approve our MCP surface (no prompt)
442
+ canUseTool, // hard deny anything else
443
+ extraArgs: { tools: '' }, // disable ALL built-in tools (= CLI --tools "")
444
+ cwd: BRIDGE_CWD,
445
+ abortController: abort,
446
+ includePartialMessages: false, // whole messages, matching the old non-partial path
447
+ permissionMode: 'default',
448
+ env: safeEnv,
449
+ stderr: (line) => { if (verbose && line.trim())
450
+ process.stderr.write(`[claude] ${line.trim()}\n`); },
451
+ ...(pinnedModel ? { model: pinnedModel } : {}),
452
+ };
453
+ const promptMessages = buildPromptMessages(history);
454
+ try {
455
+ for await (const m of query({ prompt: promptStream(promptMessages), options })) {
456
+ // Skip echoed input replays — they would double-count in the accumulator
457
+ // and re-stream prior turns to the PWA.
458
+ if (m.isReplay)
459
+ continue;
460
+ switch (m.type) {
461
+ case 'system': {
462
+ if (m.subtype === 'init') {
463
+ const init = m;
464
+ const event = { type: 'system', subtype: 'init', model: init.model, apiKeySource: init.apiKeySource };
465
+ if (handleEvent(event))
466
+ onEvent(event);
467
+ }
468
+ break;
469
+ }
470
+ case 'assistant': {
471
+ const am = m;
472
+ // Structured error signal — classify, do not forward as a real turn.
473
+ if (am.error) {
474
+ sawApiError = true;
475
+ if (am.error === 'authentication_failed' || am.error === 'oauth_org_not_allowed')
476
+ sawAuthFailure = true;
477
+ if (!apiErrorText)
478
+ apiErrorText = `API Error: ${am.error}`;
479
+ process.stderr.write(paint(SECTION_COLORS.result, `[bridge] assistant error: ${am.error}`) + '\n');
480
+ break;
481
+ }
482
+ const event = { type: 'assistant', message: am.message, error: am.error };
483
+ if (handleEvent(event))
484
+ onEvent(event);
485
+ if (killedForViolation)
486
+ return; // handleEvent already aborted + onError
487
+ break;
488
+ }
489
+ case 'user': {
490
+ const um = m;
491
+ const event = { type: 'user', message: um.message };
492
+ if (handleEvent(event))
493
+ onEvent(event);
494
+ break;
495
+ }
496
+ case 'result': {
497
+ const rm = m;
498
+ const event = {
499
+ type: 'result',
500
+ subtype: rm['subtype'],
501
+ total_cost_usd: rm['total_cost_usd'],
502
+ is_error: rm['is_error'],
503
+ api_error_status: rm['api_error_status'],
504
+ result: rm['result'],
505
+ };
506
+ if (handleEvent(event))
507
+ onEvent(event);
508
+ break;
509
+ }
510
+ case 'auth_status': {
511
+ const as = m;
512
+ if (as.error) {
513
+ sawApiError = true;
514
+ sawAuthFailure = true;
515
+ apiErrorText = as.error;
516
+ }
517
+ break;
518
+ }
519
+ case 'rate_limit_event': {
520
+ // SDK surfaces upstream rate-limit pauses; it retries internally.
521
+ // Admin-only ephemeral notice — jargon is fine in Local Mode.
522
+ onNotice?.('Claude Code is pausing briefly for an upstream rate limit, then continuing…');
523
+ break;
524
+ }
525
+ // Everything else (partial messages, status, hooks, notifications,
526
+ // thinking tokens, …) is SDK-internal — not part of the CLI event
527
+ // contract the gateway/accumulator understand, so it is dropped.
528
+ default:
529
+ break;
530
+ }
591
531
  }
592
- catch { /* ignore */ }
593
532
  }
594
- // An auth failure can land on a "successful" exit (the CLI stamps it on the
595
- // result event but still exits 0 in some versions), and we've suppressed its
596
- // text so without this the turn would finish silently empty. Treat it as a
597
- // failure regardless of exit code.
598
- if (sawAuthFailure || (code !== 0 && code !== null)) {
599
- // Auto-retry when the CLI failed BEFORE producing any real output — the
600
- // signature of the known print-mode 400 regression. A fresh spawn (new
601
- // --session-id) often succeeds. We never retry once real text or a tool
602
- // call landed, to avoid double-running a side-effectful tool. We also
603
- // never retry an auth failure — re-spawning won't add missing credentials,
604
- // it just burns the user's plan. Retries use escalating backoff and stop
605
- // past the wall-clock cap (see consts above).
606
- const elapsed = Date.now() - firstAttemptAt;
607
- if (attemptIdx < MAX_TURN_RETRIES && sawApiError && !sawAuthFailure && !producedRealOutput && elapsed < RETRY_WALL_CLOCK_CAP_MS) {
608
- const delay = RETRY_BACKOFF_BASE_MS * (attemptIdx + 1);
609
- const nextAttempt = attemptIdx + 2;
610
- 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`);
611
- // Admin-only ephemeral thread notice — jargon is fine in Local Mode.
612
- onNotice?.(`Claude Code print-mode 400 (tool-use concurrency, anthropics/claude-code#18131) — respawning, attempt ${nextAttempt}/${MAX_TURN_RETRIES + 1}…`);
613
- const timer = setTimeout(() => {
614
- pendingRetries.delete(conversationId);
615
- spawnClaude({ ...params, _attemptIdx: attemptIdx + 1, _firstAttemptAt: firstAttemptAt });
616
- }, delay);
617
- pendingRetries.set(conversationId, timer);
533
+ catch (err) {
534
+ active.delete(conversationId);
535
+ // Aborted by supersede or the Stop button no error to surface.
536
+ if (abort.signal.aborted)
618
537
  return;
538
+ if (killedForViolation)
539
+ return;
540
+ const message = err?.message ?? String(err);
541
+ if (/40[13]\b|unauthor|invalid (api key|authentication)|please run \/login/i.test(message)) {
542
+ sawAuthFailure = true;
619
543
  }
620
- // Pass any partial token usage we observed before the failure so the
621
- // PWA and the gateway's bridge usage store can still record it. Surface a
622
- // classified, user-readable message instead of the opaque exit code.
623
- onError(describeCliFailure(code, apiErrorText, sawAuthFailure), usage, extractedModel);
624
- }
625
- else {
626
- onDone(messageCount, costUsd, usage, extractedModel, lastContextTokens);
544
+ if (!apiErrorText)
545
+ apiErrorText = message;
546
+ onError(describeCliFailure(apiErrorText, sawAuthFailure), usage, extractedModel);
547
+ return;
627
548
  }
628
- });
629
- proc.on('error', (err) => {
630
549
  active.delete(conversationId);
631
- if (err.code === 'ENOENT') {
632
- onError('claude CLI not found. Please install Claude Code: https://claude.ai/code', usage, extractedModel);
550
+ if (killedForViolation)
551
+ return; // already errored
552
+ if (abort.signal.aborted)
553
+ return; // superseded/cancelled mid-stream
554
+ if (sawAuthFailure || (sawApiError && !producedRealOutput)) {
555
+ onError(describeCliFailure(apiErrorText, sawAuthFailure), usage, extractedModel);
633
556
  }
634
557
  else {
635
- onError(err.message, usage, extractedModel);
558
+ onDone(messageCount, costUsd, usage, extractedModel, lastContextTokens);
636
559
  }
637
- });
560
+ })();
638
561
  }
639
- function killAll() {
640
- for (const [, proc] of active) {
641
- proc.kill('SIGTERM');
562
+ export function killAll() {
563
+ for (const [, abort] of active) {
564
+ abort.abort();
642
565
  }
643
566
  active.clear();
644
567
  }
645
568
  /**
646
569
  * Stop one in-flight turn (the Stop button, relayed by the gateway as a
647
- * `cancel` frame). Kills the running Claude Code process for this conversation
648
- * and cancels any scheduled retry, so no further stream events are produced.
649
- * Mirrors the supersede path in spawnClaude. Returns true if something was
650
- * actually stopped. Mechanical only — no product logic lives here.
570
+ * `cancel` frame). Aborts the running query() for this conversation so no
571
+ * further stream events are produced. Mirrors the supersede path in spawnClaude.
572
+ * Returns true if something was actually stopped. Mechanical only no product
573
+ * logic lives here.
651
574
  */
652
- function cancelConversation(conversationId) {
653
- let stopped = false;
654
- const proc = active.get(conversationId);
655
- if (proc) {
656
- proc.kill('SIGTERM');
575
+ export function cancelConversation(conversationId) {
576
+ const abort = active.get(conversationId);
577
+ if (abort) {
578
+ abort.abort();
657
579
  active.delete(conversationId);
658
- stopped = true;
659
- }
660
- const pending = pendingRetries.get(conversationId);
661
- if (pending) {
662
- clearTimeout(pending);
663
- pendingRetries.delete(conversationId);
664
- stopped = true;
580
+ return true;
665
581
  }
666
- return stopped;
582
+ return false;
667
583
  }