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