@1presence/bridge 0.39.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,72 +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) {
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
- if (/API Error:\s*400/i.test(t) && /(tool use|concurren|parallel)/i.test(t)) {
160
- 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';
130
+ // Auth/credential failure (401/403). Local Mode runs the user's own Claude
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.
133
+ if (authFailure) {
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.';
161
135
  }
162
136
  if (/^API Error:/i.test(t)) {
163
137
  return `Local Mode error from Claude Code: ${t.replace(/^API Error:\s*/i, '').trim()}`;
164
138
  }
165
- 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;
187
+ }
188
+ async function* promptStream(messages) {
189
+ for (const m of messages)
190
+ yield m;
166
191
  }
167
- // ─── Spawn ────────────────────────────────────────────────────────────────────
168
- function spawnClaude(params) {
192
+ // ─── Spawn (drive one turn through the SDK) ──────────────────────────────────────
193
+ export function spawnClaude(params) {
169
194
  const { conversationId, presenceSessionId, text, uid, history, vaultFileOpen, clientCapabilities, syncedFolders, onEvent, onDone, onError, onNotice } = params;
170
- const attemptIdx = params._attemptIdx ?? 0;
171
- const firstAttemptAt = params._firstAttemptAt ?? Date.now();
172
- const systemPromptPath = (0, path_1.join)((0, os_1.tmpdir)(), `agent-${uid}.md`);
173
- 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`);
174
197
  if (verbose) {
175
198
  process.stderr.write(paint('90', `[bridge:verbose] cwd: ${BRIDGE_CWD}`) + '\n');
176
- 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');
177
200
  process.stderr.write(paint('90', `[bridge:verbose] system prompt: ${systemPromptPath}`) + '\n');
178
201
  process.stderr.write(paint('90', `[bridge:verbose] mcp config: ${mcpConfigPath}`) + '\n');
179
202
  process.stderr.write(paint('90', `[bridge:verbose] session id: ${presenceSessionId}`) + '\n');
@@ -182,22 +205,12 @@ function spawnClaude(params) {
182
205
  }
183
206
  // Surface the user's UID before the session line in every mode — it's the
184
207
  // Firestore doc prefix (`sessions/<uid>_<conversationId>`), so logging it
185
- // makes a reported bridge failure correlatable to the stored session without
186
- // having to ask which account hit it. The CLI's own `--session-id` is
187
- // ephemeral and is NOT the Firestore conversationId, so the uid is the key
188
- // join column when debugging.
208
+ // makes a reported bridge failure correlatable to the stored session.
189
209
  process.stderr.write(`[bridge] user ${uid}\n`);
190
- // Debug transcript: lead with the user prompt for this turn (the clean
191
- // message, before the gateway's ephemeral-context prefix), plus the session
192
- // id (correlates with the chat URL / Firestore session doc) and a hint at
193
- // 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.
194
212
  if (debug || verbose) {
195
213
  process.stderr.write(`\n${paint('1', `══ session ${presenceSessionId} ══`)}\n`);
196
- // `history` already ends with the new user prompt (gateway-appended). Render
197
- // every PRIOR message with its role colour so what the model saw as context
198
- // is auditable — then the live turn is shown via the clean `text` below and
199
- // streams in beneath it. Defensive: if the tail isn't the current user turn
200
- // (unexpected), render the whole history rather than dropping a message.
201
214
  const tail = history[history.length - 1];
202
215
  const prior = tail?.role === 'user' ? history.slice(0, -1) : history;
203
216
  if (prior.length > 0) {
@@ -205,407 +218,366 @@ function spawnClaude(params) {
205
218
  for (const msg of prior)
206
219
  renderHistoryMessage(msg);
207
220
  }
208
- debugBlock('user · this turn', exports.SECTION_COLORS.user, text);
221
+ debugBlock('user · this turn', SECTION_COLORS.user, text);
209
222
  }
210
223
  else {
211
- // Default mode is quiet, but always surface the session id once per turn so
212
- // it can be matched to the chat URL / Firestore session doc when debugging.
213
224
  process.stderr.write(`[bridge] session ${presenceSessionId}\n`);
214
225
  }
215
- // tool_use_id tool name, so a tool_result block (which only carries the id)
216
- // can be labelled with the tool it answers in the debug transcript.
217
- const toolNames = new Map();
218
- // If a prior process is still running for this conversation (user sent a
219
- // follow-up before the previous turn finished), supersede it. The latest
220
- // user intent wins; the orphan would otherwise keep streaming events.
221
- const existing = active.get(conversationId);
222
- if (existing) {
223
- process.stderr.write(`[bridge] superseding active conversation ${conversationId}\n`);
224
- existing.kill('SIGTERM');
225
- active.delete(conversationId);
226
- }
227
- // Cancel any retry scheduled for this conversation that hasn't fired yet.
228
- // Without this, a new user message arriving during a retry's backoff window
229
- // would race the stale retry — which carries the OLD turn's history and would
230
- // clobber the new turn. Skip when this call IS the retry firing (attemptIdx>0,
231
- // the timer already deleted itself before invoking us).
232
- if (attemptIdx === 0) {
233
- const pending = pendingRetries.get(conversationId);
234
- if (pending) {
235
- clearTimeout(pending);
236
- pendingRetries.delete(conversationId);
237
- process.stderr.write(`[bridge] cancelled pending retry for ${conversationId} (superseded by new turn)\n`);
238
- }
239
- }
240
- // Note: ephemeral context (vault_file_open / client_capabilities / synced_folders)
241
- // is injected into the last user message by the gateway BEFORE history is
242
- // sent over the WS. The bridge no longer constructs `userMessageText` —
243
- // `history` is the authoritative stream and already contains the new user
244
- // prompt with prefix prepended. The `text`, `vaultFileOpen`,
245
- // `clientCapabilities`, `syncedFolders` SpawnParams are retained for
246
- // 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.
247
229
  void vaultFileOpen;
248
230
  void clientCapabilities;
249
231
  void syncedFolders;
250
232
  void text;
251
- // Lockdown rationale:
252
- // - `--tools ""` disables ALL built-in tools (Bash/Read/Write/Edit/Glob/Grep/
253
- // WebFetch/etc.). MCP tools are not "built-in" so the 1Presence MCP surface
254
- // remains available.
255
- // - `--setting-sources ""` prevents claude CLI from loading the user's
256
- // ~/.claude/settings.json (and project/.local equivalents). Without this,
257
- // permissive `permissions.allow` rules in the user's personal Claude Code
258
- // config would silently re-enable Bash/Edit/Write etc. inside the bridge.
259
- // - `--strict-mcp-config` keeps the MCP surface to exactly what we wire in
260
- // via --mcp-config. Together these guarantee the bridge can only call
261
- // `mcp__1presence__*` — no filesystem, no shell, no arbitrary network.
262
- //
263
- // Session continuity rationale:
264
- // - `--input-format stream-json` accepts structured user/assistant messages
265
- // on stdin. We replay prior turns (loaded by the gateway from Firestore)
266
- // followed by the new user turn — this is how the bridge sees history.
267
- // - `--no-session-persistence` keeps no jsonl on disk. The bridge has zero
268
- // local filesystem dependency for continuity; Firestore is the only
269
- // source of truth.
270
- // - `--session-id <uuid>` must be a fresh UUID per spawn: the CLI treats
271
- // this flag as a "claim a new session ID" operation and rejects the
272
- // second spawn with "Session ID X is already in use" if we reuse one
273
- // across turns of a chat — even with --no-session-persistence. The
274
- // bridge passes the per-spawn `conversationId` here; the presence
275
- // sessionId is correlated separately via bridge logs and spool records.
276
- // The CLI treats --session-id as "claim this new session ID" and rejects a
277
- // reused id with "Session ID X is already in use". A retry is a fresh spawn,
278
- // so it MUST use a new uuid; the first attempt keeps the correlation id.
279
- const spawnSessionId = attemptIdx === 0 ? presenceSessionId : crypto.randomUUID();
280
- const args = [
281
- '--print',
282
- '--input-format', 'stream-json',
283
- '--output-format', 'stream-json',
284
- '--verbose',
285
- '--tools', '',
286
- '--setting-sources', '',
287
- '--allowedTools', 'mcp__1presence__*',
288
- '--system-prompt-file', systemPromptPath,
289
- '--mcp-config', mcpConfigPath,
290
- '--strict-mcp-config',
291
- '--no-session-persistence',
292
- '--session-id', spawnSessionId,
293
- ];
294
- const pinnedModel = (0, config_1.getBridgeModel)();
295
- if (pinnedModel) {
296
- args.push('--model', pinnedModel);
297
- }
298
- // Strip API key so Claude Code uses the user's claude.ai Pro subscription
299
- // (OAuth credentials), not an API key that would bill to a separate account.
300
- const { ANTHROPIC_API_KEY: _stripped, ...safeEnv } = process.env;
301
- const proc = (0, child_process_1.spawn)('claude', args, {
302
- cwd: BRIDGE_CWD,
303
- env: safeEnv,
304
- stdio: ['pipe', 'pipe', 'pipe'],
305
- });
306
- active.set(conversationId, proc);
307
- // Feed the full conversation via stdin as stream-json. The gateway's
308
- // early-save committed the new user message to Firestore BEFORE building
309
- // `history`, so `history` already ends with the new user prompt (with the
310
- // ephemeral context prefix prepended by the gateway). The bridge no longer
311
- // appends a separate `newTurn` — doing so would duplicate the user prompt.
312
- // Sanitisation (orphan tool_use stripping, displayOnly filtering, consecutive
313
- // same-role merging) already happened on the gateway via
314
- // @presence/shared.toModelMessages — replay the history verbatim.
315
- try {
316
- const stdin = proc.stdin;
317
- if (!stdin) {
318
- throw new Error('claude stdin is null — spawn must use stdio[0]="pipe"');
319
- }
320
- for (const msg of history) {
321
- // Normalise to array-of-blocks: Claude Code's stream-json input parser
322
- // iterates `content` directly. A string slips into a `"tool_use_id" in
323
- // <char>` check inside the CLI and aborts the process with `W is not an
324
- // Object` (JSC) / exit 1 mid-turn. The gateway also normalises before
325
- // sending, so a current gateway + any bridge version is safe; this guard
326
- // covers older gateways and ad-hoc local replay tests.
327
- const content = Array.isArray(msg.content)
328
- ? msg.content
329
- : [{ type: 'text', text: typeof msg.content === 'string' ? msg.content : '' }];
330
- const wrapped = { type: msg.role, message: { role: msg.role, content } };
331
- stdin.write(JSON.stringify(wrapped) + '\n');
332
- }
333
- stdin.end();
334
- }
335
- catch (err) {
336
- process.stderr.write(`[bridge] failed to write stdin: ${err.message}\n`);
337
- proc.kill('SIGTERM');
338
- onError(`stdin write failed: ${err.message}`, null, null);
339
- 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);
340
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.
341
247
  let sessionIdExtracted = false;
342
248
  let messageCount = 0;
343
249
  let costUsd = 0;
344
250
  let usage = null;
251
+ // Prompt size of the MOST RECENT assistant call (input + both cache buckets),
252
+ // overwritten on each assistant event so it ends on the turn's final, fullest
253
+ // call. This — not the summed `usage` — is the current context fill the status
254
+ // line's 🧠 segment reports against the model's window.
255
+ let lastContextTokens = 0;
345
256
  let extractedModel = null;
346
- let buffer = '';
347
257
  let killedForViolation = false;
348
- // Retry/error-surfacing tracking for this attempt:
349
- // - sawApiError: the CLI emitted an "API Error:" assistant text event (the
350
- // way Claude Code reports an underlying API failure mid-turn).
351
- // - apiErrorText: that text, captured for describeCliFailure().
352
- // - producedRealOutput: any real assistant text or tool_use was emitted, so
353
- // a later failure must NOT be retried (could double-run a side-effect).
354
258
  let sawApiError = false;
259
+ let sawAuthFailure = false;
355
260
  let apiErrorText = '';
356
261
  let producedRealOutput = false;
357
- proc.stdout.on('data', (chunk) => {
358
- buffer += chunk.toString('utf-8');
359
- const lines = buffer.split('\n');
360
- buffer = lines.pop() ?? '';
361
- for (const line of lines) {
362
- const trimmed = line.trim();
363
- if (!trimmed)
364
- continue;
365
- let event;
366
- try {
367
- 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;
368
292
  }
369
- catch {
370
- continue;
293
+ else {
294
+ process.stderr.write(`[bridge] model: ${model ?? 'unknown'} apiKeySource: ${keySource ?? 'none'}\n`);
371
295
  }
372
- const type = event['type'];
373
- // Set when this event is the CLI's "API Error:" turn — we neither forward
374
- // it to the PWA nor let it reach the accumulator (it carries no real
375
- // content and would poison history / show a raw error mid-stream).
376
- let suppressEvent = false;
377
- // Extract model + key source info from the first system/init event.
378
- // No session-id persistence — Firestore is the only source of truth
379
- // now, and we pin --session-id to presenceSessionId on every spawn.
380
- if (!sessionIdExtracted && type === 'system' && event['subtype'] === 'init') {
381
- const keySource = event['apiKeySource'];
382
- const model = event['model'];
383
- if (model)
384
- extractedModel = model;
385
- if (!modelAnnounced) {
386
- // First conversation since bridge started — announce prominently
387
- // so the user can confirm which model and credential is in use.
388
- const source = keySource === 'none' || !keySource ? 'claude.ai subscription' : keySource;
389
- const pin = (0, config_1.getBridgeModel)() ? ' (selected at startup)' : '';
390
- process.stdout.write(`\n model: ${model ?? 'unknown'}${pin}\n auth: ${source}\n\n`);
391
- modelAnnounced = true;
392
- }
393
- else {
394
- // Subsequent conversations — quiet line for power users.
395
- process.stderr.write(`[bridge] model: ${model ?? 'unknown'} apiKeySource: ${keySource ?? 'none'}\n`);
396
- }
397
- 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);
398
310
  }
399
- // Count complete assistant turns + accumulate token usage + log tool calls
400
- if (type === 'assistant') {
401
- messageCount++;
402
- const msg = event['message'];
403
- const u = msg?.['usage'];
404
- if (u) {
405
- usage = {
406
- input_tokens: (usage?.input_tokens ?? 0) + (u['input_tokens'] ?? 0),
407
- output_tokens: (usage?.output_tokens ?? 0) + (u['output_tokens'] ?? 0),
408
- cache_read_input_tokens: (usage?.cache_read_input_tokens ?? 0) + (u['cache_read_input_tokens'] ?? 0),
409
- cache_creation_input_tokens: (usage?.cache_creation_input_tokens ?? 0) + (u['cache_creation_input_tokens'] ?? 0),
410
- };
411
- }
412
- const content = msg?.['content'];
413
- if (Array.isArray(content)) {
414
- let wroteText = false;
415
- for (const block of content) {
416
- if (block['type'] === 'tool_use') {
417
- producedRealOutput = true;
418
- const toolName = block['name'];
419
- const toolId = block['id'];
420
- if (toolId)
421
- toolNames.set(toolId, toolName);
422
- if (debug) {
423
- // Clean transcript: a single coloured block with the full input.
424
- debugBlock(`tool → ${toolName}`, exports.SECTION_COLORS.input, formatPayload(block['input']));
425
- }
426
- else {
427
- if (wroteText) {
428
- process.stderr.write('\n');
429
- wroteText = false;
430
- }
431
- const prefix = toolName.startsWith('mcp__') ? '[mcp]' : '[tool]';
432
- process.stderr.write(paint(exports.SECTION_COLORS.input, `[bridge] ${prefix} ${toolName}`) + '\n');
433
- if (verbose) {
434
- const input = block['input'];
435
- process.stderr.write(paint(exports.SECTION_COLORS.input, `[bridge:verbose] ─── input ${toolName} ───\n${formatPayload(input)}\n[bridge:verbose] ─── end input ───`) + '\n');
436
- }
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;
437
328
  }
438
- // Defense-in-depth: CLI flags (--tools "", --allowedTools, --strict-mcp-config,
439
- // --setting-sources "") are supposed to make this unreachable. If we see a
440
- // non-1Presence tool here anyway, something has bypassed those guards — kill
441
- // immediately so any side effect already in flight is the only damage done.
442
- //
443
- // Valid forms:
444
- // mcp__1presence__<name> — namespaced MCP form
445
- // <snake_case_name> — bare form; Claude Code may omit the prefix in
446
- // stream-json output. Safe because --strict-mcp-config
447
- // limits MCP to the 1presence server only.
448
- // Invalid (real violations):
449
- // PascalCase (Bash, Read, Write, …) — Claude Code built-ins
450
- // mcp__<other>__* — tools from a different MCP server
451
- const isMcp1presence = toolName.startsWith('mcp__1presence__');
452
- const isBareName = /^[a-z][a-z0-9_]*$/.test(toolName);
453
- if (!isMcp1presence && !isBareName) {
454
- killedForViolation = true;
455
- const violation = `bridge tool violation: ${toolName} is not allowed in Local Mode`;
456
- process.stderr.write(`[bridge] FATAL ${violation} — killing\n`);
457
- active.delete(conversationId);
458
- proc.kill('SIGKILL');
459
- onError(violation, usage, extractedModel);
460
- 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');
461
333
  }
462
334
  }
463
- else if (block['type'] === 'text') {
464
- const text = block['text'];
465
- if (text) {
466
- if (/^API Error:/i.test(text.trimStart())) {
467
- // The CLI is reporting an underlying API failure as assistant
468
- // text. Capture it for the user-facing message, and suppress
469
- // the whole event so the raw error never reaches the PWA or
470
- // the accumulator (the gateway also blanks it via
471
- // cleanTurnTextthis is the upstream defense).
472
- sawApiError = true;
473
- apiErrorText = text.trim();
474
- suppressEvent = true;
475
- process.stderr.write(paint(exports.SECTION_COLORS.result, `[bridge] ${text.replace(/\n+/g, ' ')}`) + '\n');
476
- }
477
- else {
478
- producedRealOutput = true;
479
- if (debug) {
480
- // Full text, newlines intact — the readable transcript.
481
- debugBlock('assistant', exports.SECTION_COLORS.assistant, text);
482
- }
483
- else {
484
- process.stderr.write(paint(exports.SECTION_COLORS.assistant, text.replace(/\n+/g, ' ')));
485
- wroteText = true;
486
- }
487
- }
488
- }
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;
489
348
  }
490
349
  }
491
- if (wroteText)
492
- process.stderr.write('\n');
493
- }
494
- }
495
- // Tool results stream back as `user` events with tool_result blocks.
496
- if ((verbose || debug) && type === 'user') {
497
- const msg = event['message'];
498
- const content = msg?.['content'];
499
- if (Array.isArray(content)) {
500
- for (const block of content) {
501
- if (block['type'] === 'tool_result') {
502
- const id = block['tool_use_id'] ?? '';
503
- 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;
504
369
  if (debug) {
505
- const name = toolNames.get(id) ?? id ?? 'result';
506
- const errFlag = block['is_error'] ? ' [error]' : '';
507
- debugBlock(`result ← ${name}${errFlag}`, exports.SECTION_COLORS.result, formatPayload(out));
370
+ debugBlock('assistant', SECTION_COLORS.assistant, blockText);
508
371
  }
509
372
  else {
510
- 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;
511
375
  }
512
376
  }
513
377
  }
514
378
  }
379
+ if (wroteText)
380
+ process.stderr.write('\n');
381
+ }
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
+ }
399
+ }
400
+ }
515
401
  }
516
- // Extract cost from the final result event
517
- if (type === 'result') {
518
- const c = event['cost_usd'] ?? event['total_cost_usd'];
519
- if (typeof c === 'number')
520
- costUsd = c;
402
+ }
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
+ }
521
417
  }
522
- if (!suppressEvent)
523
- onEvent(event);
524
418
  }
525
- });
526
- proc.stderr.on('data', (chunk) => {
527
- // claude CLI writes logs to stderr not errors, just noise
528
- const line = chunk.toString('utf-8').trim();
529
- if (line)
530
- process.stderr.write(`[claude] ${line}\n`);
531
- });
532
- proc.on('close', (code) => {
533
- active.delete(conversationId);
534
- // Violation path already called onError + killed — don't double-fire.
535
- if (killedForViolation)
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);
536
434
  return;
537
- // Flush any remaining buffer
538
- if (buffer.trim()) {
539
- try {
540
- 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
+ }
541
531
  }
542
- catch { /* ignore */ }
543
532
  }
544
- if (code !== 0 && code !== null) {
545
- // Auto-retry when the CLI failed BEFORE producing any real output — the
546
- // signature of the known print-mode 400 regression. A fresh spawn (new
547
- // --session-id) often succeeds. We never retry once real text or a tool
548
- // call landed, to avoid double-running a side-effectful tool. Retries use
549
- // escalating backoff and stop past the wall-clock cap (see consts above).
550
- const elapsed = Date.now() - firstAttemptAt;
551
- if (attemptIdx < MAX_TURN_RETRIES && sawApiError && !producedRealOutput && elapsed < RETRY_WALL_CLOCK_CAP_MS) {
552
- const delay = RETRY_BACKOFF_BASE_MS * (attemptIdx + 1);
553
- const nextAttempt = attemptIdx + 2;
554
- 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`);
555
- // Admin-only ephemeral thread notice — jargon is fine in Local Mode.
556
- onNotice?.(`Claude Code print-mode 400 (tool-use concurrency, anthropics/claude-code#18131) — respawning, attempt ${nextAttempt}/${MAX_TURN_RETRIES + 1}…`);
557
- const timer = setTimeout(() => {
558
- pendingRetries.delete(conversationId);
559
- spawnClaude({ ...params, _attemptIdx: attemptIdx + 1, _firstAttemptAt: firstAttemptAt });
560
- }, delay);
561
- 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)
537
+ return;
538
+ if (killedForViolation)
562
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;
563
543
  }
564
- // Pass any partial token usage we observed before the failure so the
565
- // PWA and the gateway's bridge usage store can still record it. Surface a
566
- // classified, user-readable message instead of the opaque exit code.
567
- onError(describeCliFailure(code, apiErrorText), usage, extractedModel);
568
- }
569
- else {
570
- onDone(messageCount, costUsd, usage, extractedModel);
544
+ if (!apiErrorText)
545
+ apiErrorText = message;
546
+ onError(describeCliFailure(apiErrorText, sawAuthFailure), usage, extractedModel);
547
+ return;
571
548
  }
572
- });
573
- proc.on('error', (err) => {
574
549
  active.delete(conversationId);
575
- if (err.code === 'ENOENT') {
576
- 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);
577
556
  }
578
557
  else {
579
- onError(err.message, usage, extractedModel);
558
+ onDone(messageCount, costUsd, usage, extractedModel, lastContextTokens);
580
559
  }
581
- });
560
+ })();
582
561
  }
583
- function killAll() {
584
- for (const [, proc] of active) {
585
- proc.kill('SIGTERM');
562
+ export function killAll() {
563
+ for (const [, abort] of active) {
564
+ abort.abort();
586
565
  }
587
566
  active.clear();
588
567
  }
589
568
  /**
590
569
  * Stop one in-flight turn (the Stop button, relayed by the gateway as a
591
- * `cancel` frame). Kills the running Claude Code process for this conversation
592
- * and cancels any scheduled retry, so no further stream events are produced.
593
- * Mirrors the supersede path in spawnClaude. Returns true if something was
594
- * 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.
595
574
  */
596
- function cancelConversation(conversationId) {
597
- let stopped = false;
598
- const proc = active.get(conversationId);
599
- if (proc) {
600
- proc.kill('SIGTERM');
575
+ export function cancelConversation(conversationId) {
576
+ const abort = active.get(conversationId);
577
+ if (abort) {
578
+ abort.abort();
601
579
  active.delete(conversationId);
602
- stopped = true;
603
- }
604
- const pending = pendingRetries.get(conversationId);
605
- if (pending) {
606
- clearTimeout(pending);
607
- pendingRetries.delete(conversationId);
608
- stopped = true;
580
+ return true;
609
581
  }
610
- return stopped;
582
+ return false;
611
583
  }