@1presence/bridge 0.40.0 → 0.43.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/accumulator.js +2 -6
- package/dist/auth.js +16 -23
- package/dist/claude.js +447 -491
- package/dist/config.js +6 -10
- package/dist/index.js +68 -62
- package/dist/outbox.js +13 -18
- package/dist/timer.js +3 -8
- package/dist/update.js +8 -8
- package/package.json +3 -1
package/dist/claude.js
CHANGED
|
@@ -1,42 +1,41 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
|
17
|
-
// ~/.claude/CLAUDE.md. The bridge runs in a dedicated temp dir
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
// user's global ~/.claude/CLAUDE.md
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
|
|
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
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
74
|
+
export const SECTION_COLORS = {
|
|
76
75
|
system: '35', // magenta
|
|
77
76
|
user: '34', // blue
|
|
78
77
|
assistant: '32', // green
|
|
@@ -83,6 +82,26 @@ function debugBlock(label, colorCode, body) {
|
|
|
83
82
|
const rule = `── ${label} `.padEnd(64, '─');
|
|
84
83
|
process.stderr.write(`\n${paint(colorCode, rule)}\n${body.trimEnd()}\n`);
|
|
85
84
|
}
|
|
85
|
+
// Strip confabulated tool-call XML out of assistant text. Real tool calls arrive
|
|
86
|
+
// as structured tool_use blocks, never as text — so when assistant text contains
|
|
87
|
+
// <function_calls>/<function_results>/<invoke>, the model is role-playing the tool
|
|
88
|
+
// protocol because it had no callable tools, and that raw XML (often invented
|
|
89
|
+
// internal-looking content) must never reach the user. Kept as a tiny local copy
|
|
90
|
+
// rather than importing @presence/shared so the published bridge stays decoupled.
|
|
91
|
+
const TOOL_CALL_XML_RE = /<function_calls\b|<function_results\b|<invoke\b/i;
|
|
92
|
+
function stripToolCallXml(text) {
|
|
93
|
+
if (!text || !TOOL_CALL_XML_RE.test(text))
|
|
94
|
+
return text;
|
|
95
|
+
let out = text
|
|
96
|
+
.replace(/<function_calls>[\s\S]*?<\/function_calls>/gi, '')
|
|
97
|
+
.replace(/<function_results>[\s\S]*?<\/function_results>/gi, '')
|
|
98
|
+
.replace(/<invoke\b[\s\S]*?<\/invoke>/gi, '');
|
|
99
|
+
// Truncated / unclosed opener: drop from the first stray opener to the end.
|
|
100
|
+
out = out.replace(/<function_calls\b[\s\S]*$/i, '')
|
|
101
|
+
.replace(/<function_results\b[\s\S]*$/i, '')
|
|
102
|
+
.replace(/<invoke\b[\s\S]*$/i, '');
|
|
103
|
+
return out.trim();
|
|
104
|
+
}
|
|
86
105
|
// Render one replayed-history content block as a single readable line for the
|
|
87
106
|
// debug transcript. Tool calls and results are inlined so a history turn shows
|
|
88
107
|
// exactly what the model received — text, the tools it ran, and what they
|
|
@@ -108,79 +127,96 @@ function summariseHistoryBlock(block) {
|
|
|
108
127
|
// can tell user turns from assistant turns at a glance — the missing
|
|
109
128
|
// distinction that made replayed context unreadable in --debug.
|
|
110
129
|
function renderHistoryMessage(msg) {
|
|
111
|
-
const color = msg.role === 'user' ?
|
|
130
|
+
const color = msg.role === 'user' ? SECTION_COLORS.user : SECTION_COLORS.assistant;
|
|
112
131
|
const body = typeof msg.content === 'string'
|
|
113
132
|
? msg.content
|
|
114
133
|
: msg.content.map(summariseHistoryBlock).join('\n');
|
|
115
134
|
debugBlock(`${msg.role} · history`, color, body);
|
|
116
135
|
}
|
|
117
|
-
// ─── Active
|
|
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.
|
|
136
|
+
// ─── Active turns ───────────────────────────────────────────────────────────────
|
|
133
137
|
//
|
|
134
|
-
//
|
|
135
|
-
//
|
|
136
|
-
//
|
|
137
|
-
|
|
138
|
-
//
|
|
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,
|
|
138
|
+
// conversationId → AbortController for the in-flight query(). Aborting cancels
|
|
139
|
+
// the turn (supersede on a new message, or the Stop button via the gateway's
|
|
140
|
+
// `cancel` frame). The SDK's query() loop ends when its controller aborts.
|
|
141
|
+
const active = new Map();
|
|
142
|
+
// Map a thrown query() error / captured "API Error:" text to a concise,
|
|
146
143
|
// user-facing Local Mode message. The raw upstream text stays in operator logs
|
|
147
144
|
// only — we never echo a wall of provider error JSON into the chat. Referring
|
|
148
145
|
// to "Claude Code" here is intentional and consistent with Local Mode's other
|
|
149
|
-
// operational errors
|
|
150
|
-
//
|
|
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) {
|
|
146
|
+
// operational errors: in Local Mode the user is knowingly running their own
|
|
147
|
+
// Claude Code install.
|
|
148
|
+
function describeCliFailure(apiErrorText, authFailure) {
|
|
158
149
|
const t = apiErrorText.trim();
|
|
159
150
|
// Auth/credential failure (401/403). Local Mode runs the user's own Claude
|
|
160
|
-
// Code, so naming it (and /login) is intentional
|
|
161
|
-
//
|
|
162
|
-
// how to recover. Takes precedence over the generic branches below.
|
|
151
|
+
// Code, so naming it (and /login) is intentional — this is the only place
|
|
152
|
+
// that can tell them how to recover. Takes precedence over generic branches.
|
|
163
153
|
if (authFailure) {
|
|
164
154
|
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
155
|
}
|
|
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
156
|
if (/^API Error:/i.test(t)) {
|
|
170
157
|
return `Local Mode error from Claude Code: ${t.replace(/^API Error:\s*/i, '').trim()}`;
|
|
171
158
|
}
|
|
172
|
-
|
|
159
|
+
if (t) {
|
|
160
|
+
return `Local Mode stopped unexpectedly: ${t}`;
|
|
161
|
+
}
|
|
162
|
+
return 'Local Mode stopped unexpectedly. Please try again.';
|
|
173
163
|
}
|
|
174
|
-
// ───
|
|
175
|
-
|
|
164
|
+
// ─── Prompt construction ─────────────────────────────────────────────────────────
|
|
165
|
+
//
|
|
166
|
+
// The gateway pushes the FULL conversation (sanitised via @presence/shared
|
|
167
|
+
// toModelMessages) and `history` already ends with the new user turn. The SDK's
|
|
168
|
+
// streaming input only triggers an assistant turn for user messages whose
|
|
169
|
+
// `shouldQuery` is not false — so we replay every PRIOR turn with
|
|
170
|
+
// shouldQuery:false (appended to the transcript, no turn generated), inject
|
|
171
|
+
// assistant turns verbatim (carrying their tool_use blocks), and let ONLY the
|
|
172
|
+
// final/live user turn run. This preserves stateless structured replay: no
|
|
173
|
+
// session resume, no flat-text collapse, no local jsonl — Firestore stays the
|
|
174
|
+
// single source of truth, exactly as the CLI stdin replay did.
|
|
175
|
+
function buildPromptMessages(history) {
|
|
176
|
+
// Index of the live user turn — the last user-role message. The gateway
|
|
177
|
+
// always appends it; the scan is defensive against an unexpected tail.
|
|
178
|
+
let liveIdx = -1;
|
|
179
|
+
for (let i = history.length - 1; i >= 0; i--) {
|
|
180
|
+
if (history[i].role === 'user') {
|
|
181
|
+
liveIdx = i;
|
|
182
|
+
break;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
const out = [];
|
|
186
|
+
history.forEach((msg, i) => {
|
|
187
|
+
// Normalise to array-of-blocks (a bare string becomes a single text block).
|
|
188
|
+
const content = Array.isArray(msg.content)
|
|
189
|
+
? msg.content
|
|
190
|
+
: [{ type: 'text', text: typeof msg.content === 'string' ? msg.content : '' }];
|
|
191
|
+
if (msg.role === 'assistant') {
|
|
192
|
+
// Injected verbatim — runtime accepts a {type:'assistant'} message on the
|
|
193
|
+
// input stream and appends it to the transcript (it never triggers a turn).
|
|
194
|
+
out.push({ type: 'assistant', message: { role: 'assistant', content }, parent_tool_use_id: null });
|
|
195
|
+
}
|
|
196
|
+
else {
|
|
197
|
+
const isLive = i === liveIdx;
|
|
198
|
+
out.push({
|
|
199
|
+
type: 'user',
|
|
200
|
+
...(isLive ? {} : { shouldQuery: false }),
|
|
201
|
+
message: { role: 'user', content },
|
|
202
|
+
parent_tool_use_id: null,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
return out;
|
|
207
|
+
}
|
|
208
|
+
async function* promptStream(messages) {
|
|
209
|
+
for (const m of messages)
|
|
210
|
+
yield m;
|
|
211
|
+
}
|
|
212
|
+
// ─── Spawn (drive one turn through the SDK) ──────────────────────────────────────
|
|
213
|
+
export function spawnClaude(params) {
|
|
176
214
|
const { conversationId, presenceSessionId, text, uid, history, vaultFileOpen, clientCapabilities, syncedFolders, onEvent, onDone, onError, onNotice } = params;
|
|
177
|
-
const
|
|
178
|
-
const
|
|
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`);
|
|
215
|
+
const systemPromptPath = join(tmpdir(), `agent-${uid}.md`);
|
|
216
|
+
const mcpConfigPath = join(tmpdir(), `mcp-${uid}.json`);
|
|
181
217
|
if (verbose) {
|
|
182
218
|
process.stderr.write(paint('90', `[bridge:verbose] cwd: ${BRIDGE_CWD}`) + '\n');
|
|
183
|
-
process.stderr.write(paint('90', `[bridge:verbose] override md: ${
|
|
219
|
+
process.stderr.write(paint('90', `[bridge:verbose] override md: ${join(BRIDGE_CWD, 'CLAUDE.md')}`) + '\n');
|
|
184
220
|
process.stderr.write(paint('90', `[bridge:verbose] system prompt: ${systemPromptPath}`) + '\n');
|
|
185
221
|
process.stderr.write(paint('90', `[bridge:verbose] mcp config: ${mcpConfigPath}`) + '\n');
|
|
186
222
|
process.stderr.write(paint('90', `[bridge:verbose] session id: ${presenceSessionId}`) + '\n');
|
|
@@ -189,22 +225,12 @@ function spawnClaude(params) {
|
|
|
189
225
|
}
|
|
190
226
|
// Surface the user's UID before the session line in every mode — it's the
|
|
191
227
|
// Firestore doc prefix (`sessions/<uid>_<conversationId>`), so logging it
|
|
192
|
-
// makes a reported bridge failure correlatable to the stored session
|
|
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.
|
|
228
|
+
// makes a reported bridge failure correlatable to the stored session.
|
|
196
229
|
process.stderr.write(`[bridge] user ${uid}\n`);
|
|
197
|
-
// Debug transcript: lead with the
|
|
198
|
-
//
|
|
199
|
-
// id (correlates with the chat URL / Firestore session doc) and a hint at
|
|
200
|
-
// how much prior context is being replayed.
|
|
230
|
+
// Debug transcript: lead with the prior context (replayed history) then the
|
|
231
|
+
// live user prompt. `history` already ends with the new user turn.
|
|
201
232
|
if (debug || verbose) {
|
|
202
233
|
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
234
|
const tail = history[history.length - 1];
|
|
209
235
|
const prior = tail?.role === 'user' ? history.slice(0, -1) : history;
|
|
210
236
|
if (prior.length > 0) {
|
|
@@ -212,456 +238,386 @@ function spawnClaude(params) {
|
|
|
212
238
|
for (const msg of prior)
|
|
213
239
|
renderHistoryMessage(msg);
|
|
214
240
|
}
|
|
215
|
-
debugBlock('user · this turn',
|
|
241
|
+
debugBlock('user · this turn', SECTION_COLORS.user, text);
|
|
216
242
|
}
|
|
217
243
|
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
244
|
process.stderr.write(`[bridge] session ${presenceSessionId}\n`);
|
|
221
245
|
}
|
|
222
|
-
//
|
|
223
|
-
//
|
|
224
|
-
|
|
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.
|
|
246
|
+
// ephemeral context (vault_file_open / client_capabilities / synced_folders) is
|
|
247
|
+
// injected into the last user message by the gateway BEFORE history is sent —
|
|
248
|
+
// these params are retained for backward-compatible logging only.
|
|
254
249
|
void vaultFileOpen;
|
|
255
250
|
void clientCapabilities;
|
|
256
251
|
void syncedFolders;
|
|
257
252
|
void text;
|
|
258
|
-
//
|
|
259
|
-
//
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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;
|
|
253
|
+
// Supersede any in-flight turn for this conversation (user sent a follow-up
|
|
254
|
+
// before the previous turn finished). The latest intent wins.
|
|
255
|
+
const existing = active.get(conversationId);
|
|
256
|
+
if (existing) {
|
|
257
|
+
process.stderr.write(`[bridge] superseding active conversation ${conversationId}\n`);
|
|
258
|
+
existing.abort();
|
|
259
|
+
active.delete(conversationId);
|
|
347
260
|
}
|
|
261
|
+
const abort = new AbortController();
|
|
262
|
+
active.set(conversationId, abort);
|
|
263
|
+
// tool_use_id → tool name, so a tool_result block (which only carries the id)
|
|
264
|
+
// can be labelled with the tool it answers in the debug transcript.
|
|
265
|
+
const toolNames = new Map();
|
|
266
|
+
// Per-turn accounting.
|
|
348
267
|
let sessionIdExtracted = false;
|
|
349
268
|
let messageCount = 0;
|
|
350
269
|
let costUsd = 0;
|
|
351
270
|
let usage = null;
|
|
352
271
|
// Prompt size of the MOST RECENT assistant call (input + both cache buckets),
|
|
353
272
|
// overwritten on each assistant event so it ends on the turn's final, fullest
|
|
354
|
-
// call. This — not the summed `usage`
|
|
355
|
-
//
|
|
273
|
+
// call. This — not the summed `usage` — is the current context fill the status
|
|
274
|
+
// line's 🧠 segment reports against the model's window.
|
|
356
275
|
let lastContextTokens = 0;
|
|
357
276
|
let extractedModel = null;
|
|
358
|
-
let buffer = '';
|
|
359
277
|
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
278
|
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
279
|
let sawAuthFailure = false;
|
|
371
280
|
let apiErrorText = '';
|
|
372
281
|
let producedRealOutput = false;
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
282
|
+
// Allow only the 1Presence MCP surface to execute. Built-in tools are disabled
|
|
283
|
+
// via extraArgs `--tools ""`; this is the runtime safety net (a hard deny that
|
|
284
|
+
// runs before any execution) for anything that slips past. Our MCP tools are
|
|
285
|
+
// auto-approved via allowedTools, so this callback only ever fires to deny.
|
|
286
|
+
const canUseTool = async (toolName, input) => {
|
|
287
|
+
if (toolName.startsWith('mcp__1presence__'))
|
|
288
|
+
return { behavior: 'allow', updatedInput: input };
|
|
289
|
+
return { behavior: 'deny', message: `Tool ${toolName} is not allowed in Local Mode`, interrupt: true };
|
|
290
|
+
};
|
|
291
|
+
// Strip API key so Claude Code uses the user's claude.ai subscription (OAuth
|
|
292
|
+
// credentials in the Keychain), not an API key that would bill a separate
|
|
293
|
+
// account. Options.env REPLACES the subprocess env, so spread the rest through.
|
|
294
|
+
const { ANTHROPIC_API_KEY: _stripped, ...safeEnv } = process.env;
|
|
295
|
+
const pinnedModel = getBridgeModel();
|
|
296
|
+
// Process one translated raw stream-json event: bookkeeping + forward. Mirrors
|
|
297
|
+
// the old CLI stdout parser so the gateway/accumulator see identical shapes.
|
|
298
|
+
// Returns false when the event must be suppressed (errors) or the turn was
|
|
299
|
+
// killed for a tool violation.
|
|
300
|
+
function handleEvent(event) {
|
|
301
|
+
const type = event['type'];
|
|
302
|
+
if (!sessionIdExtracted && type === 'system' && event['subtype'] === 'init') {
|
|
303
|
+
const keySource = event['apiKeySource'];
|
|
304
|
+
const model = event['model'];
|
|
305
|
+
if (model)
|
|
306
|
+
extractedModel = model;
|
|
307
|
+
if (!modelAnnounced) {
|
|
308
|
+
const source = keySource === 'none' || !keySource ? 'claude.ai subscription' : keySource;
|
|
309
|
+
const pin = getBridgeModel() ? ' (selected at startup)' : '';
|
|
310
|
+
process.stdout.write(`\n model: ${model ?? 'unknown'}${pin}\n auth: ${source}\n\n`);
|
|
311
|
+
modelAnnounced = true;
|
|
384
312
|
}
|
|
385
|
-
|
|
386
|
-
|
|
313
|
+
else {
|
|
314
|
+
process.stderr.write(`[bridge] model: ${model ?? 'unknown'} apiKeySource: ${keySource ?? 'none'}\n`);
|
|
387
315
|
}
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
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;
|
|
316
|
+
sessionIdExtracted = true;
|
|
317
|
+
}
|
|
318
|
+
if (type === 'assistant') {
|
|
319
|
+
messageCount++;
|
|
320
|
+
const msg = event['message'];
|
|
321
|
+
const u = msg?.['usage'];
|
|
322
|
+
if (u) {
|
|
323
|
+
usage = {
|
|
324
|
+
input_tokens: (usage?.input_tokens ?? 0) + (u['input_tokens'] ?? 0),
|
|
325
|
+
output_tokens: (usage?.output_tokens ?? 0) + (u['output_tokens'] ?? 0),
|
|
326
|
+
cache_read_input_tokens: (usage?.cache_read_input_tokens ?? 0) + (u['cache_read_input_tokens'] ?? 0),
|
|
327
|
+
cache_creation_input_tokens: (usage?.cache_creation_input_tokens ?? 0) + (u['cache_creation_input_tokens'] ?? 0),
|
|
328
|
+
};
|
|
329
|
+
lastContextTokens = (u['input_tokens'] ?? 0) + (u['cache_read_input_tokens'] ?? 0) + (u['cache_creation_input_tokens'] ?? 0);
|
|
414
330
|
}
|
|
415
|
-
|
|
416
|
-
if (
|
|
417
|
-
|
|
418
|
-
const
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
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
|
-
}
|
|
331
|
+
const content = msg?.['content'];
|
|
332
|
+
if (Array.isArray(content)) {
|
|
333
|
+
let wroteText = false;
|
|
334
|
+
for (const block of content) {
|
|
335
|
+
if (block['type'] === 'tool_use') {
|
|
336
|
+
producedRealOutput = true;
|
|
337
|
+
const toolName = block['name'];
|
|
338
|
+
const toolId = block['id'];
|
|
339
|
+
if (toolId)
|
|
340
|
+
toolNames.set(toolId, toolName);
|
|
341
|
+
if (debug) {
|
|
342
|
+
debugBlock(`tool → ${toolName}`, SECTION_COLORS.input, formatPayload(block['input']));
|
|
343
|
+
}
|
|
344
|
+
else {
|
|
345
|
+
if (wroteText) {
|
|
346
|
+
process.stderr.write('\n');
|
|
347
|
+
wroteText = false;
|
|
457
348
|
}
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
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;
|
|
349
|
+
const prefix = toolName.startsWith('mcp__') ? '[mcp]' : '[tool]';
|
|
350
|
+
process.stderr.write(paint(SECTION_COLORS.input, `[bridge] ${prefix} ${toolName}`) + '\n');
|
|
351
|
+
if (verbose) {
|
|
352
|
+
process.stderr.write(paint(SECTION_COLORS.input, `[bridge:verbose] ─── input ${toolName} ───\n${formatPayload(block['input'])}\n[bridge:verbose] ─── end input ───`) + '\n');
|
|
481
353
|
}
|
|
482
354
|
}
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
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
|
-
}
|
|
355
|
+
// Defense-in-depth: canUseTool + --tools "" + strictMcpConfig should
|
|
356
|
+
// make a non-1Presence tool unreachable. If one appears anyway, kill
|
|
357
|
+
// the turn so any side effect in flight is the only damage done.
|
|
358
|
+
const isMcp1presence = toolName.startsWith('mcp__1presence__');
|
|
359
|
+
const isBareName = /^[a-z][a-z0-9_]*$/.test(toolName);
|
|
360
|
+
if (!isMcp1presence && !isBareName) {
|
|
361
|
+
killedForViolation = true;
|
|
362
|
+
const violation = `bridge tool violation: ${toolName} is not allowed in Local Mode`;
|
|
363
|
+
process.stderr.write(`[bridge] FATAL ${violation} — aborting\n`);
|
|
364
|
+
active.delete(conversationId);
|
|
365
|
+
abort.abort();
|
|
366
|
+
onError(violation, usage, extractedModel);
|
|
367
|
+
return false;
|
|
525
368
|
}
|
|
526
369
|
}
|
|
527
|
-
if (
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
370
|
+
else if (block['type'] === 'text') {
|
|
371
|
+
let blockText = block['text'];
|
|
372
|
+
// Drop confabulated tool-call XML before this event is forwarded to
|
|
373
|
+
// the gateway (onEvent forwards THIS object, so mutate it in place).
|
|
374
|
+
// Happens when a turn ran with no callable tools and the model
|
|
375
|
+
// role-played the protocol in prose. See vault/Bugs.md 2026-05-28.
|
|
376
|
+
if (blockText && TOOL_CALL_XML_RE.test(blockText)) {
|
|
377
|
+
const cleaned = stripToolCallXml(blockText);
|
|
378
|
+
if (cleaned !== blockText) {
|
|
379
|
+
process.stderr.write(paint(SECTION_COLORS.result, `[bridge] suppressed confabulated tool-call XML in assistant text`) + '\n');
|
|
380
|
+
block['text'] = cleaned;
|
|
381
|
+
blockText = cleaned;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
if (blockText) {
|
|
385
|
+
// The CLI/SDK can report auth/API failures as a synthetic assistant
|
|
386
|
+
// text turn whose wording varies. Detect by the structured signal
|
|
387
|
+
// (event.error) plus a wording fallback, so it's classified rather
|
|
388
|
+
// than leaking raw into the chat as if the model had said it.
|
|
389
|
+
const isSynthetic = msg?.['model'] === '<synthetic>';
|
|
390
|
+
const isAuthFailure = event['error'] === 'authentication_failed' ||
|
|
391
|
+
(isSynthetic && /(api error:\s*40[13]\b|invalid (api key|authentication)|please run \/login|failed to authenticate|unauthor)/i.test(blockText));
|
|
392
|
+
if (/^API Error:/i.test(blockText.trimStart()) || isAuthFailure) {
|
|
393
|
+
sawApiError = true;
|
|
394
|
+
apiErrorText = blockText.trim();
|
|
395
|
+
if (isAuthFailure)
|
|
396
|
+
sawAuthFailure = true;
|
|
397
|
+
process.stderr.write(paint(SECTION_COLORS.result, `[bridge] ${blockText.replace(/\n+/g, ' ')}`) + '\n');
|
|
398
|
+
return false; // suppress — never forward a raw error turn
|
|
399
|
+
}
|
|
400
|
+
producedRealOutput = true;
|
|
540
401
|
if (debug) {
|
|
541
|
-
|
|
542
|
-
const errFlag = block['is_error'] ? ' [error]' : '';
|
|
543
|
-
debugBlock(`result ← ${name}${errFlag}`, exports.SECTION_COLORS.result, formatPayload(out));
|
|
402
|
+
debugBlock('assistant', SECTION_COLORS.assistant, blockText);
|
|
544
403
|
}
|
|
545
404
|
else {
|
|
546
|
-
process.stderr.write(paint(
|
|
405
|
+
process.stderr.write(paint(SECTION_COLORS.assistant, blockText.replace(/\n+/g, ' ')));
|
|
406
|
+
wroteText = true;
|
|
547
407
|
}
|
|
548
408
|
}
|
|
549
409
|
}
|
|
550
410
|
}
|
|
411
|
+
if (wroteText)
|
|
412
|
+
process.stderr.write('\n');
|
|
551
413
|
}
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
414
|
+
}
|
|
415
|
+
if ((verbose || debug) && type === 'user') {
|
|
416
|
+
const msg = event['message'];
|
|
417
|
+
const content = msg?.['content'];
|
|
418
|
+
if (Array.isArray(content)) {
|
|
419
|
+
for (const block of content) {
|
|
420
|
+
if (block['type'] === 'tool_result') {
|
|
421
|
+
const id = block['tool_use_id'] ?? '';
|
|
422
|
+
const out = block['content'];
|
|
423
|
+
if (debug) {
|
|
424
|
+
const name = toolNames.get(id) ?? id ?? 'result';
|
|
425
|
+
const errFlag = block['is_error'] ? ' [error]' : '';
|
|
426
|
+
debugBlock(`result ← ${name}${errFlag}`, SECTION_COLORS.result, formatPayload(out));
|
|
427
|
+
}
|
|
428
|
+
else {
|
|
429
|
+
process.stderr.write(paint(SECTION_COLORS.result, `[bridge:verbose] ─── output ${id} ───\n${formatPayload(out)}\n[bridge:verbose] ─── end output ───`) + '\n');
|
|
430
|
+
}
|
|
569
431
|
}
|
|
570
432
|
}
|
|
571
433
|
}
|
|
572
|
-
if (!suppressEvent)
|
|
573
|
-
onEvent(event);
|
|
574
434
|
}
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
435
|
+
if (type === 'result') {
|
|
436
|
+
// total_cost_usd is the SDK's notional figure (0 on the no-op history
|
|
437
|
+
// append cycle; the real number on the live turn). Keep the largest seen.
|
|
438
|
+
const c = event['total_cost_usd'] ?? event['cost_usd'];
|
|
439
|
+
if (typeof c === 'number' && c > costUsd)
|
|
440
|
+
costUsd = c;
|
|
441
|
+
if (event['is_error'] === true) {
|
|
442
|
+
sawApiError = true;
|
|
443
|
+
const status = event['api_error_status'];
|
|
444
|
+
if (status === 401 || status === 403)
|
|
445
|
+
sawAuthFailure = true;
|
|
446
|
+
if (!apiErrorText && typeof event['result'] === 'string') {
|
|
447
|
+
apiErrorText = event['result'].trim();
|
|
448
|
+
}
|
|
449
|
+
// Operator visibility — a result-borne error would otherwise reach the
|
|
450
|
+
// user as a chat error with NOTHING in the bridge logs (the failure
|
|
451
|
+
// mode that made the empty-content `invalid_request` regression so hard
|
|
452
|
+
// to diagnose). Mechanical log only; no product logic.
|
|
453
|
+
process.stderr.write(paint(SECTION_COLORS.result, `[bridge] result error${status ? ` (${status})` : ''}: ${apiErrorText || 'unknown'}`) + '\n');
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
return true;
|
|
457
|
+
}
|
|
458
|
+
// Drive the turn. Synchronous spawnClaude returns immediately; the SDK loop
|
|
459
|
+
// runs in this async IIFE and fires the same callbacks the CLI path did.
|
|
460
|
+
void (async () => {
|
|
461
|
+
let systemPrompt;
|
|
462
|
+
let mcpServers;
|
|
463
|
+
try {
|
|
464
|
+
systemPrompt = readFileSync(systemPromptPath, 'utf-8');
|
|
465
|
+
const mcpRaw = JSON.parse(readFileSync(mcpConfigPath, 'utf-8'));
|
|
466
|
+
mcpServers = mcpRaw.mcpServers ?? {};
|
|
467
|
+
}
|
|
468
|
+
catch (err) {
|
|
469
|
+
active.delete(conversationId);
|
|
470
|
+
onError(`Local Mode setup files unavailable: ${err.message}`, null, null);
|
|
586
471
|
return;
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
472
|
+
}
|
|
473
|
+
const options = {
|
|
474
|
+
systemPrompt, // custom string → replaces the default Claude Code prompt
|
|
475
|
+
mcpServers: mcpServers,
|
|
476
|
+
strictMcpConfig: true, // only our MCP server, ignore project/user/plugin MCP
|
|
477
|
+
settingSources: [], // no user/project settings or memory
|
|
478
|
+
allowedTools: ['mcp__1presence__*'], // auto-approve our MCP surface (no prompt)
|
|
479
|
+
canUseTool, // hard deny anything else
|
|
480
|
+
tools: [], // disable ALL built-in tools; MCP tools come via mcpServers and survive.
|
|
481
|
+
// (The old `extraArgs: { tools: '' }` passed a malformed --tools "" that
|
|
482
|
+
// cleared the whole tool surface — including MCP — so the model had no
|
|
483
|
+
// callable tools and confabulated tool calls as text. See vault/Bugs.md.)
|
|
484
|
+
cwd: BRIDGE_CWD,
|
|
485
|
+
abortController: abort,
|
|
486
|
+
includePartialMessages: false, // whole messages, matching the old non-partial path
|
|
487
|
+
permissionMode: 'default',
|
|
488
|
+
env: safeEnv,
|
|
489
|
+
stderr: (line) => { if (verbose && line.trim())
|
|
490
|
+
process.stderr.write(`[claude] ${line.trim()}\n`); },
|
|
491
|
+
...(pinnedModel ? { model: pinnedModel } : {}),
|
|
492
|
+
};
|
|
493
|
+
const promptMessages = buildPromptMessages(history);
|
|
494
|
+
try {
|
|
495
|
+
for await (const m of query({ prompt: promptStream(promptMessages), options })) {
|
|
496
|
+
// Skip echoed input replays — they would double-count in the accumulator
|
|
497
|
+
// and re-stream prior turns to the PWA.
|
|
498
|
+
if (m.isReplay)
|
|
499
|
+
continue;
|
|
500
|
+
switch (m.type) {
|
|
501
|
+
case 'system': {
|
|
502
|
+
if (m.subtype === 'init') {
|
|
503
|
+
const init = m;
|
|
504
|
+
const event = { type: 'system', subtype: 'init', model: init.model, apiKeySource: init.apiKeySource };
|
|
505
|
+
if (handleEvent(event))
|
|
506
|
+
onEvent(event);
|
|
507
|
+
}
|
|
508
|
+
break;
|
|
509
|
+
}
|
|
510
|
+
case 'assistant': {
|
|
511
|
+
const am = m;
|
|
512
|
+
// Structured error signal — classify, do not forward as a real turn.
|
|
513
|
+
if (am.error) {
|
|
514
|
+
sawApiError = true;
|
|
515
|
+
if (am.error === 'authentication_failed' || am.error === 'oauth_org_not_allowed')
|
|
516
|
+
sawAuthFailure = true;
|
|
517
|
+
if (!apiErrorText)
|
|
518
|
+
apiErrorText = `API Error: ${am.error}`;
|
|
519
|
+
process.stderr.write(paint(SECTION_COLORS.result, `[bridge] assistant error: ${am.error}`) + '\n');
|
|
520
|
+
break;
|
|
521
|
+
}
|
|
522
|
+
const event = { type: 'assistant', message: am.message, error: am.error };
|
|
523
|
+
if (handleEvent(event))
|
|
524
|
+
onEvent(event);
|
|
525
|
+
if (killedForViolation)
|
|
526
|
+
return; // handleEvent already aborted + onError
|
|
527
|
+
break;
|
|
528
|
+
}
|
|
529
|
+
case 'user': {
|
|
530
|
+
const um = m;
|
|
531
|
+
const event = { type: 'user', message: um.message };
|
|
532
|
+
if (handleEvent(event))
|
|
533
|
+
onEvent(event);
|
|
534
|
+
break;
|
|
535
|
+
}
|
|
536
|
+
case 'result': {
|
|
537
|
+
const rm = m;
|
|
538
|
+
const event = {
|
|
539
|
+
type: 'result',
|
|
540
|
+
subtype: rm['subtype'],
|
|
541
|
+
total_cost_usd: rm['total_cost_usd'],
|
|
542
|
+
is_error: rm['is_error'],
|
|
543
|
+
api_error_status: rm['api_error_status'],
|
|
544
|
+
result: rm['result'],
|
|
545
|
+
};
|
|
546
|
+
if (handleEvent(event))
|
|
547
|
+
onEvent(event);
|
|
548
|
+
break;
|
|
549
|
+
}
|
|
550
|
+
case 'auth_status': {
|
|
551
|
+
const as = m;
|
|
552
|
+
if (as.error) {
|
|
553
|
+
sawApiError = true;
|
|
554
|
+
sawAuthFailure = true;
|
|
555
|
+
apiErrorText = as.error;
|
|
556
|
+
}
|
|
557
|
+
break;
|
|
558
|
+
}
|
|
559
|
+
case 'rate_limit_event': {
|
|
560
|
+
// SDK surfaces upstream rate-limit pauses; it retries internally.
|
|
561
|
+
// Admin-only ephemeral notice — jargon is fine in Local Mode.
|
|
562
|
+
onNotice?.('Claude Code is pausing briefly for an upstream rate limit, then continuing…');
|
|
563
|
+
break;
|
|
564
|
+
}
|
|
565
|
+
// Everything else (partial messages, status, hooks, notifications,
|
|
566
|
+
// thinking tokens, …) is SDK-internal — not part of the CLI event
|
|
567
|
+
// contract the gateway/accumulator understand, so it is dropped.
|
|
568
|
+
default:
|
|
569
|
+
break;
|
|
570
|
+
}
|
|
591
571
|
}
|
|
592
|
-
catch { /* ignore */ }
|
|
593
572
|
}
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
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);
|
|
573
|
+
catch (err) {
|
|
574
|
+
active.delete(conversationId);
|
|
575
|
+
// Aborted by supersede or the Stop button — no error to surface.
|
|
576
|
+
if (abort.signal.aborted)
|
|
577
|
+
return;
|
|
578
|
+
if (killedForViolation)
|
|
618
579
|
return;
|
|
580
|
+
const message = err?.message ?? String(err);
|
|
581
|
+
if (/40[13]\b|unauthor|invalid (api key|authentication)|please run \/login/i.test(message)) {
|
|
582
|
+
sawAuthFailure = true;
|
|
619
583
|
}
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
}
|
|
625
|
-
else {
|
|
626
|
-
onDone(messageCount, costUsd, usage, extractedModel, lastContextTokens);
|
|
584
|
+
if (!apiErrorText)
|
|
585
|
+
apiErrorText = message;
|
|
586
|
+
onError(describeCliFailure(apiErrorText, sawAuthFailure), usage, extractedModel);
|
|
587
|
+
return;
|
|
627
588
|
}
|
|
628
|
-
});
|
|
629
|
-
proc.on('error', (err) => {
|
|
630
589
|
active.delete(conversationId);
|
|
631
|
-
if (
|
|
632
|
-
|
|
590
|
+
if (killedForViolation)
|
|
591
|
+
return; // already errored
|
|
592
|
+
if (abort.signal.aborted)
|
|
593
|
+
return; // superseded/cancelled mid-stream
|
|
594
|
+
if (sawAuthFailure || (sawApiError && !producedRealOutput)) {
|
|
595
|
+
onError(describeCliFailure(apiErrorText, sawAuthFailure), usage, extractedModel);
|
|
633
596
|
}
|
|
634
597
|
else {
|
|
635
|
-
|
|
598
|
+
onDone(messageCount, costUsd, usage, extractedModel, lastContextTokens);
|
|
636
599
|
}
|
|
637
|
-
});
|
|
600
|
+
})();
|
|
638
601
|
}
|
|
639
|
-
function killAll() {
|
|
640
|
-
for (const [,
|
|
641
|
-
|
|
602
|
+
export function killAll() {
|
|
603
|
+
for (const [, abort] of active) {
|
|
604
|
+
abort.abort();
|
|
642
605
|
}
|
|
643
606
|
active.clear();
|
|
644
607
|
}
|
|
645
608
|
/**
|
|
646
609
|
* Stop one in-flight turn (the Stop button, relayed by the gateway as a
|
|
647
|
-
* `cancel` frame).
|
|
648
|
-
*
|
|
649
|
-
*
|
|
650
|
-
*
|
|
610
|
+
* `cancel` frame). Aborts the running query() for this conversation so no
|
|
611
|
+
* further stream events are produced. Mirrors the supersede path in spawnClaude.
|
|
612
|
+
* Returns true if something was actually stopped. Mechanical only — no product
|
|
613
|
+
* logic lives here.
|
|
651
614
|
*/
|
|
652
|
-
function cancelConversation(conversationId) {
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
proc.kill('SIGTERM');
|
|
615
|
+
export function cancelConversation(conversationId) {
|
|
616
|
+
const abort = active.get(conversationId);
|
|
617
|
+
if (abort) {
|
|
618
|
+
abort.abort();
|
|
657
619
|
active.delete(conversationId);
|
|
658
|
-
|
|
659
|
-
}
|
|
660
|
-
const pending = pendingRetries.get(conversationId);
|
|
661
|
-
if (pending) {
|
|
662
|
-
clearTimeout(pending);
|
|
663
|
-
pendingRetries.delete(conversationId);
|
|
664
|
-
stopped = true;
|
|
620
|
+
return true;
|
|
665
621
|
}
|
|
666
|
-
return
|
|
622
|
+
return false;
|
|
667
623
|
}
|