@1presence/bridge 0.39.0 → 0.42.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/accumulator.js +2 -6
- package/dist/auth.js +16 -23
- package/dist/claude.js +416 -444
- package/dist/config.js +6 -10
- package/dist/index.js +116 -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
|
|
@@ -108,72 +107,96 @@ function summariseHistoryBlock(block) {
|
|
|
108
107
|
// can tell user turns from assistant turns at a glance — the missing
|
|
109
108
|
// distinction that made replayed context unreadable in --debug.
|
|
110
109
|
function renderHistoryMessage(msg) {
|
|
111
|
-
const color = msg.role === 'user' ?
|
|
110
|
+
const color = msg.role === 'user' ? SECTION_COLORS.user : SECTION_COLORS.assistant;
|
|
112
111
|
const body = typeof msg.content === 'string'
|
|
113
112
|
? msg.content
|
|
114
113
|
: msg.content.map(summariseHistoryBlock).join('\n');
|
|
115
114
|
debugBlock(`${msg.role} · history`, color, body);
|
|
116
115
|
}
|
|
117
|
-
// ─── Active
|
|
118
|
-
const active = new Map();
|
|
119
|
-
// conversationId → pending retry timer. A retry is scheduled with a backoff
|
|
120
|
-
// delay, during which the conversation has NO entry in `active`. If a new user
|
|
121
|
-
// message arrives in that window it must cancel the stale retry (otherwise the
|
|
122
|
-
// retry would re-run the OLD turn's history and clobber the new one). The
|
|
123
|
-
// supersede block clears any pending timer here before spawning.
|
|
124
|
-
const pendingRetries = new Map();
|
|
125
|
-
// Automatic retries when the `claude` CLI exits non-zero BEFORE producing any
|
|
126
|
-
// real output. This covers the known Claude Code print-mode 400 regression that
|
|
127
|
-
// surfaces as "API Error: 400 due to tool use concurrency issues" (GitHub
|
|
128
|
-
// anthropics/claude-code#18131, still open) — it is non-deterministic enough
|
|
129
|
-
// that a fresh spawn often succeeds. We retry ONLY when the failed attempt
|
|
130
|
-
// produced no real assistant text and no tool calls, so a failure that lands
|
|
131
|
-
// after real work (where retrying could double-execute a side-effectful tool)
|
|
132
|
-
// is surfaced, never silently re-run.
|
|
116
|
+
// ─── Active turns ───────────────────────────────────────────────────────────────
|
|
133
117
|
//
|
|
134
|
-
//
|
|
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,
|
|
118
|
+
// conversationId → AbortController for the in-flight query(). Aborting cancels
|
|
119
|
+
// the turn (supersede on a new message, or the Stop button via the gateway's
|
|
120
|
+
// `cancel` frame). The SDK's query() loop ends when its controller aborts.
|
|
121
|
+
const active = new Map();
|
|
122
|
+
// Map a thrown query() error / captured "API Error:" text to a concise,
|
|
146
123
|
// user-facing Local Mode message. The raw upstream text stays in operator logs
|
|
147
124
|
// only — we never echo a wall of provider error JSON into the chat. Referring
|
|
148
125
|
// to "Claude Code" here is intentional and consistent with Local Mode's other
|
|
149
|
-
// operational errors
|
|
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) {
|
|
126
|
+
// operational errors: in Local Mode the user is knowingly running their own
|
|
127
|
+
// Claude Code install.
|
|
128
|
+
function describeCliFailure(apiErrorText, authFailure) {
|
|
158
129
|
const t = apiErrorText.trim();
|
|
159
|
-
|
|
160
|
-
|
|
130
|
+
// Auth/credential failure (401/403). Local Mode runs the user's own Claude
|
|
131
|
+
// Code, so naming it (and /login) is intentional — this is the only place
|
|
132
|
+
// that can tell them how to recover. Takes precedence over generic branches.
|
|
133
|
+
if (authFailure) {
|
|
134
|
+
return 'Local Mode could not sign in to Claude Code on this machine. Open a terminal, run `claude` and sign in (or run /login inside Claude Code), then send your message again.';
|
|
161
135
|
}
|
|
162
136
|
if (/^API Error:/i.test(t)) {
|
|
163
137
|
return `Local Mode error from Claude Code: ${t.replace(/^API Error:\s*/i, '').trim()}`;
|
|
164
138
|
}
|
|
165
|
-
|
|
139
|
+
if (t) {
|
|
140
|
+
return `Local Mode stopped unexpectedly: ${t}`;
|
|
141
|
+
}
|
|
142
|
+
return 'Local Mode stopped unexpectedly. Please try again.';
|
|
143
|
+
}
|
|
144
|
+
// ─── Prompt construction ─────────────────────────────────────────────────────────
|
|
145
|
+
//
|
|
146
|
+
// The gateway pushes the FULL conversation (sanitised via @presence/shared
|
|
147
|
+
// toModelMessages) and `history` already ends with the new user turn. The SDK's
|
|
148
|
+
// streaming input only triggers an assistant turn for user messages whose
|
|
149
|
+
// `shouldQuery` is not false — so we replay every PRIOR turn with
|
|
150
|
+
// shouldQuery:false (appended to the transcript, no turn generated), inject
|
|
151
|
+
// assistant turns verbatim (carrying their tool_use blocks), and let ONLY the
|
|
152
|
+
// final/live user turn run. This preserves stateless structured replay: no
|
|
153
|
+
// session resume, no flat-text collapse, no local jsonl — Firestore stays the
|
|
154
|
+
// single source of truth, exactly as the CLI stdin replay did.
|
|
155
|
+
function buildPromptMessages(history) {
|
|
156
|
+
// Index of the live user turn — the last user-role message. The gateway
|
|
157
|
+
// always appends it; the scan is defensive against an unexpected tail.
|
|
158
|
+
let liveIdx = -1;
|
|
159
|
+
for (let i = history.length - 1; i >= 0; i--) {
|
|
160
|
+
if (history[i].role === 'user') {
|
|
161
|
+
liveIdx = i;
|
|
162
|
+
break;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
const out = [];
|
|
166
|
+
history.forEach((msg, i) => {
|
|
167
|
+
// Normalise to array-of-blocks (a bare string becomes a single text block).
|
|
168
|
+
const content = Array.isArray(msg.content)
|
|
169
|
+
? msg.content
|
|
170
|
+
: [{ type: 'text', text: typeof msg.content === 'string' ? msg.content : '' }];
|
|
171
|
+
if (msg.role === 'assistant') {
|
|
172
|
+
// Injected verbatim — runtime accepts a {type:'assistant'} message on the
|
|
173
|
+
// input stream and appends it to the transcript (it never triggers a turn).
|
|
174
|
+
out.push({ type: 'assistant', message: { role: 'assistant', content }, parent_tool_use_id: null });
|
|
175
|
+
}
|
|
176
|
+
else {
|
|
177
|
+
const isLive = i === liveIdx;
|
|
178
|
+
out.push({
|
|
179
|
+
type: 'user',
|
|
180
|
+
...(isLive ? {} : { shouldQuery: false }),
|
|
181
|
+
message: { role: 'user', content },
|
|
182
|
+
parent_tool_use_id: null,
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
return out;
|
|
187
|
+
}
|
|
188
|
+
async function* promptStream(messages) {
|
|
189
|
+
for (const m of messages)
|
|
190
|
+
yield m;
|
|
166
191
|
}
|
|
167
|
-
// ─── Spawn
|
|
168
|
-
function spawnClaude(params) {
|
|
192
|
+
// ─── Spawn (drive one turn through the SDK) ──────────────────────────────────────
|
|
193
|
+
export function spawnClaude(params) {
|
|
169
194
|
const { conversationId, presenceSessionId, text, uid, history, vaultFileOpen, clientCapabilities, syncedFolders, onEvent, onDone, onError, onNotice } = params;
|
|
170
|
-
const
|
|
171
|
-
const
|
|
172
|
-
const systemPromptPath = (0, path_1.join)((0, os_1.tmpdir)(), `agent-${uid}.md`);
|
|
173
|
-
const mcpConfigPath = (0, path_1.join)((0, os_1.tmpdir)(), `mcp-${uid}.json`);
|
|
195
|
+
const systemPromptPath = join(tmpdir(), `agent-${uid}.md`);
|
|
196
|
+
const mcpConfigPath = join(tmpdir(), `mcp-${uid}.json`);
|
|
174
197
|
if (verbose) {
|
|
175
198
|
process.stderr.write(paint('90', `[bridge:verbose] cwd: ${BRIDGE_CWD}`) + '\n');
|
|
176
|
-
process.stderr.write(paint('90', `[bridge:verbose] override md: ${
|
|
199
|
+
process.stderr.write(paint('90', `[bridge:verbose] override md: ${join(BRIDGE_CWD, 'CLAUDE.md')}`) + '\n');
|
|
177
200
|
process.stderr.write(paint('90', `[bridge:verbose] system prompt: ${systemPromptPath}`) + '\n');
|
|
178
201
|
process.stderr.write(paint('90', `[bridge:verbose] mcp config: ${mcpConfigPath}`) + '\n');
|
|
179
202
|
process.stderr.write(paint('90', `[bridge:verbose] session id: ${presenceSessionId}`) + '\n');
|
|
@@ -182,22 +205,12 @@ function spawnClaude(params) {
|
|
|
182
205
|
}
|
|
183
206
|
// Surface the user's UID before the session line in every mode — it's the
|
|
184
207
|
// Firestore doc prefix (`sessions/<uid>_<conversationId>`), so logging it
|
|
185
|
-
// makes a reported bridge failure correlatable to the stored session
|
|
186
|
-
// having to ask which account hit it. The CLI's own `--session-id` is
|
|
187
|
-
// ephemeral and is NOT the Firestore conversationId, so the uid is the key
|
|
188
|
-
// join column when debugging.
|
|
208
|
+
// makes a reported bridge failure correlatable to the stored session.
|
|
189
209
|
process.stderr.write(`[bridge] user ${uid}\n`);
|
|
190
|
-
// Debug transcript: lead with the
|
|
191
|
-
//
|
|
192
|
-
// id (correlates with the chat URL / Firestore session doc) and a hint at
|
|
193
|
-
// how much prior context is being replayed.
|
|
210
|
+
// Debug transcript: lead with the prior context (replayed history) then the
|
|
211
|
+
// live user prompt. `history` already ends with the new user turn.
|
|
194
212
|
if (debug || verbose) {
|
|
195
213
|
process.stderr.write(`\n${paint('1', `══ session ${presenceSessionId} ══`)}\n`);
|
|
196
|
-
// `history` already ends with the new user prompt (gateway-appended). Render
|
|
197
|
-
// every PRIOR message with its role colour so what the model saw as context
|
|
198
|
-
// is auditable — then the live turn is shown via the clean `text` below and
|
|
199
|
-
// streams in beneath it. Defensive: if the tail isn't the current user turn
|
|
200
|
-
// (unexpected), render the whole history rather than dropping a message.
|
|
201
214
|
const tail = history[history.length - 1];
|
|
202
215
|
const prior = tail?.role === 'user' ? history.slice(0, -1) : history;
|
|
203
216
|
if (prior.length > 0) {
|
|
@@ -205,407 +218,366 @@ function spawnClaude(params) {
|
|
|
205
218
|
for (const msg of prior)
|
|
206
219
|
renderHistoryMessage(msg);
|
|
207
220
|
}
|
|
208
|
-
debugBlock('user · this turn',
|
|
221
|
+
debugBlock('user · this turn', SECTION_COLORS.user, text);
|
|
209
222
|
}
|
|
210
223
|
else {
|
|
211
|
-
// Default mode is quiet, but always surface the session id once per turn so
|
|
212
|
-
// it can be matched to the chat URL / Firestore session doc when debugging.
|
|
213
224
|
process.stderr.write(`[bridge] session ${presenceSessionId}\n`);
|
|
214
225
|
}
|
|
215
|
-
//
|
|
216
|
-
//
|
|
217
|
-
|
|
218
|
-
// If a prior process is still running for this conversation (user sent a
|
|
219
|
-
// follow-up before the previous turn finished), supersede it. The latest
|
|
220
|
-
// user intent wins; the orphan would otherwise keep streaming events.
|
|
221
|
-
const existing = active.get(conversationId);
|
|
222
|
-
if (existing) {
|
|
223
|
-
process.stderr.write(`[bridge] superseding active conversation ${conversationId}\n`);
|
|
224
|
-
existing.kill('SIGTERM');
|
|
225
|
-
active.delete(conversationId);
|
|
226
|
-
}
|
|
227
|
-
// Cancel any retry scheduled for this conversation that hasn't fired yet.
|
|
228
|
-
// Without this, a new user message arriving during a retry's backoff window
|
|
229
|
-
// would race the stale retry — which carries the OLD turn's history and would
|
|
230
|
-
// clobber the new turn. Skip when this call IS the retry firing (attemptIdx>0,
|
|
231
|
-
// the timer already deleted itself before invoking us).
|
|
232
|
-
if (attemptIdx === 0) {
|
|
233
|
-
const pending = pendingRetries.get(conversationId);
|
|
234
|
-
if (pending) {
|
|
235
|
-
clearTimeout(pending);
|
|
236
|
-
pendingRetries.delete(conversationId);
|
|
237
|
-
process.stderr.write(`[bridge] cancelled pending retry for ${conversationId} (superseded by new turn)\n`);
|
|
238
|
-
}
|
|
239
|
-
}
|
|
240
|
-
// Note: ephemeral context (vault_file_open / client_capabilities / synced_folders)
|
|
241
|
-
// is injected into the last user message by the gateway BEFORE history is
|
|
242
|
-
// sent over the WS. The bridge no longer constructs `userMessageText` —
|
|
243
|
-
// `history` is the authoritative stream and already contains the new user
|
|
244
|
-
// prompt with prefix prepended. The `text`, `vaultFileOpen`,
|
|
245
|
-
// `clientCapabilities`, `syncedFolders` SpawnParams are retained for
|
|
246
|
-
// backward-compatible logging / spool correlation only.
|
|
226
|
+
// ephemeral context (vault_file_open / client_capabilities / synced_folders) is
|
|
227
|
+
// injected into the last user message by the gateway BEFORE history is sent —
|
|
228
|
+
// these params are retained for backward-compatible logging only.
|
|
247
229
|
void vaultFileOpen;
|
|
248
230
|
void clientCapabilities;
|
|
249
231
|
void syncedFolders;
|
|
250
232
|
void text;
|
|
251
|
-
//
|
|
252
|
-
//
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
// config would silently re-enable Bash/Edit/Write etc. inside the bridge.
|
|
259
|
-
// - `--strict-mcp-config` keeps the MCP surface to exactly what we wire in
|
|
260
|
-
// via --mcp-config. Together these guarantee the bridge can only call
|
|
261
|
-
// `mcp__1presence__*` — no filesystem, no shell, no arbitrary network.
|
|
262
|
-
//
|
|
263
|
-
// Session continuity rationale:
|
|
264
|
-
// - `--input-format stream-json` accepts structured user/assistant messages
|
|
265
|
-
// on stdin. We replay prior turns (loaded by the gateway from Firestore)
|
|
266
|
-
// followed by the new user turn — this is how the bridge sees history.
|
|
267
|
-
// - `--no-session-persistence` keeps no jsonl on disk. The bridge has zero
|
|
268
|
-
// local filesystem dependency for continuity; Firestore is the only
|
|
269
|
-
// source of truth.
|
|
270
|
-
// - `--session-id <uuid>` must be a fresh UUID per spawn: the CLI treats
|
|
271
|
-
// this flag as a "claim a new session ID" operation and rejects the
|
|
272
|
-
// second spawn with "Session ID X is already in use" if we reuse one
|
|
273
|
-
// across turns of a chat — even with --no-session-persistence. The
|
|
274
|
-
// bridge passes the per-spawn `conversationId` here; the presence
|
|
275
|
-
// sessionId is correlated separately via bridge logs and spool records.
|
|
276
|
-
// The CLI treats --session-id as "claim this new session ID" and rejects a
|
|
277
|
-
// reused id with "Session ID X is already in use". A retry is a fresh spawn,
|
|
278
|
-
// so it MUST use a new uuid; the first attempt keeps the correlation id.
|
|
279
|
-
const spawnSessionId = attemptIdx === 0 ? presenceSessionId : crypto.randomUUID();
|
|
280
|
-
const args = [
|
|
281
|
-
'--print',
|
|
282
|
-
'--input-format', 'stream-json',
|
|
283
|
-
'--output-format', 'stream-json',
|
|
284
|
-
'--verbose',
|
|
285
|
-
'--tools', '',
|
|
286
|
-
'--setting-sources', '',
|
|
287
|
-
'--allowedTools', 'mcp__1presence__*',
|
|
288
|
-
'--system-prompt-file', systemPromptPath,
|
|
289
|
-
'--mcp-config', mcpConfigPath,
|
|
290
|
-
'--strict-mcp-config',
|
|
291
|
-
'--no-session-persistence',
|
|
292
|
-
'--session-id', spawnSessionId,
|
|
293
|
-
];
|
|
294
|
-
const pinnedModel = (0, config_1.getBridgeModel)();
|
|
295
|
-
if (pinnedModel) {
|
|
296
|
-
args.push('--model', pinnedModel);
|
|
297
|
-
}
|
|
298
|
-
// Strip API key so Claude Code uses the user's claude.ai Pro subscription
|
|
299
|
-
// (OAuth credentials), not an API key that would bill to a separate account.
|
|
300
|
-
const { ANTHROPIC_API_KEY: _stripped, ...safeEnv } = process.env;
|
|
301
|
-
const proc = (0, child_process_1.spawn)('claude', args, {
|
|
302
|
-
cwd: BRIDGE_CWD,
|
|
303
|
-
env: safeEnv,
|
|
304
|
-
stdio: ['pipe', 'pipe', 'pipe'],
|
|
305
|
-
});
|
|
306
|
-
active.set(conversationId, proc);
|
|
307
|
-
// Feed the full conversation via stdin as stream-json. The gateway's
|
|
308
|
-
// early-save committed the new user message to Firestore BEFORE building
|
|
309
|
-
// `history`, so `history` already ends with the new user prompt (with the
|
|
310
|
-
// ephemeral context prefix prepended by the gateway). The bridge no longer
|
|
311
|
-
// appends a separate `newTurn` — doing so would duplicate the user prompt.
|
|
312
|
-
// Sanitisation (orphan tool_use stripping, displayOnly filtering, consecutive
|
|
313
|
-
// same-role merging) already happened on the gateway via
|
|
314
|
-
// @presence/shared.toModelMessages — replay the history verbatim.
|
|
315
|
-
try {
|
|
316
|
-
const stdin = proc.stdin;
|
|
317
|
-
if (!stdin) {
|
|
318
|
-
throw new Error('claude stdin is null — spawn must use stdio[0]="pipe"');
|
|
319
|
-
}
|
|
320
|
-
for (const msg of history) {
|
|
321
|
-
// Normalise to array-of-blocks: Claude Code's stream-json input parser
|
|
322
|
-
// iterates `content` directly. A string slips into a `"tool_use_id" in
|
|
323
|
-
// <char>` check inside the CLI and aborts the process with `W is not an
|
|
324
|
-
// Object` (JSC) / exit 1 mid-turn. The gateway also normalises before
|
|
325
|
-
// sending, so a current gateway + any bridge version is safe; this guard
|
|
326
|
-
// covers older gateways and ad-hoc local replay tests.
|
|
327
|
-
const content = Array.isArray(msg.content)
|
|
328
|
-
? msg.content
|
|
329
|
-
: [{ type: 'text', text: typeof msg.content === 'string' ? msg.content : '' }];
|
|
330
|
-
const wrapped = { type: msg.role, message: { role: msg.role, content } };
|
|
331
|
-
stdin.write(JSON.stringify(wrapped) + '\n');
|
|
332
|
-
}
|
|
333
|
-
stdin.end();
|
|
334
|
-
}
|
|
335
|
-
catch (err) {
|
|
336
|
-
process.stderr.write(`[bridge] failed to write stdin: ${err.message}\n`);
|
|
337
|
-
proc.kill('SIGTERM');
|
|
338
|
-
onError(`stdin write failed: ${err.message}`, null, null);
|
|
339
|
-
return;
|
|
233
|
+
// Supersede any in-flight turn for this conversation (user sent a follow-up
|
|
234
|
+
// before the previous turn finished). The latest intent wins.
|
|
235
|
+
const existing = active.get(conversationId);
|
|
236
|
+
if (existing) {
|
|
237
|
+
process.stderr.write(`[bridge] superseding active conversation ${conversationId}\n`);
|
|
238
|
+
existing.abort();
|
|
239
|
+
active.delete(conversationId);
|
|
340
240
|
}
|
|
241
|
+
const abort = new AbortController();
|
|
242
|
+
active.set(conversationId, abort);
|
|
243
|
+
// tool_use_id → tool name, so a tool_result block (which only carries the id)
|
|
244
|
+
// can be labelled with the tool it answers in the debug transcript.
|
|
245
|
+
const toolNames = new Map();
|
|
246
|
+
// Per-turn accounting.
|
|
341
247
|
let sessionIdExtracted = false;
|
|
342
248
|
let messageCount = 0;
|
|
343
249
|
let costUsd = 0;
|
|
344
250
|
let usage = null;
|
|
251
|
+
// Prompt size of the MOST RECENT assistant call (input + both cache buckets),
|
|
252
|
+
// overwritten on each assistant event so it ends on the turn's final, fullest
|
|
253
|
+
// call. This — not the summed `usage` — is the current context fill the status
|
|
254
|
+
// line's 🧠 segment reports against the model's window.
|
|
255
|
+
let lastContextTokens = 0;
|
|
345
256
|
let extractedModel = null;
|
|
346
|
-
let buffer = '';
|
|
347
257
|
let killedForViolation = false;
|
|
348
|
-
// Retry/error-surfacing tracking for this attempt:
|
|
349
|
-
// - sawApiError: the CLI emitted an "API Error:" assistant text event (the
|
|
350
|
-
// way Claude Code reports an underlying API failure mid-turn).
|
|
351
|
-
// - apiErrorText: that text, captured for describeCliFailure().
|
|
352
|
-
// - producedRealOutput: any real assistant text or tool_use was emitted, so
|
|
353
|
-
// a later failure must NOT be retried (could double-run a side-effect).
|
|
354
258
|
let sawApiError = false;
|
|
259
|
+
let sawAuthFailure = false;
|
|
355
260
|
let apiErrorText = '';
|
|
356
261
|
let producedRealOutput = false;
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
262
|
+
// Allow only the 1Presence MCP surface to execute. Built-in tools are disabled
|
|
263
|
+
// via extraArgs `--tools ""`; this is the runtime safety net (a hard deny that
|
|
264
|
+
// runs before any execution) for anything that slips past. Our MCP tools are
|
|
265
|
+
// auto-approved via allowedTools, so this callback only ever fires to deny.
|
|
266
|
+
const canUseTool = async (toolName, input) => {
|
|
267
|
+
if (toolName.startsWith('mcp__1presence__'))
|
|
268
|
+
return { behavior: 'allow', updatedInput: input };
|
|
269
|
+
return { behavior: 'deny', message: `Tool ${toolName} is not allowed in Local Mode`, interrupt: true };
|
|
270
|
+
};
|
|
271
|
+
// Strip API key so Claude Code uses the user's claude.ai subscription (OAuth
|
|
272
|
+
// credentials in the Keychain), not an API key that would bill a separate
|
|
273
|
+
// account. Options.env REPLACES the subprocess env, so spread the rest through.
|
|
274
|
+
const { ANTHROPIC_API_KEY: _stripped, ...safeEnv } = process.env;
|
|
275
|
+
const pinnedModel = getBridgeModel();
|
|
276
|
+
// Process one translated raw stream-json event: bookkeeping + forward. Mirrors
|
|
277
|
+
// the old CLI stdout parser so the gateway/accumulator see identical shapes.
|
|
278
|
+
// Returns false when the event must be suppressed (errors) or the turn was
|
|
279
|
+
// killed for a tool violation.
|
|
280
|
+
function handleEvent(event) {
|
|
281
|
+
const type = event['type'];
|
|
282
|
+
if (!sessionIdExtracted && type === 'system' && event['subtype'] === 'init') {
|
|
283
|
+
const keySource = event['apiKeySource'];
|
|
284
|
+
const model = event['model'];
|
|
285
|
+
if (model)
|
|
286
|
+
extractedModel = model;
|
|
287
|
+
if (!modelAnnounced) {
|
|
288
|
+
const source = keySource === 'none' || !keySource ? 'claude.ai subscription' : keySource;
|
|
289
|
+
const pin = getBridgeModel() ? ' (selected at startup)' : '';
|
|
290
|
+
process.stdout.write(`\n model: ${model ?? 'unknown'}${pin}\n auth: ${source}\n\n`);
|
|
291
|
+
modelAnnounced = true;
|
|
368
292
|
}
|
|
369
|
-
|
|
370
|
-
|
|
293
|
+
else {
|
|
294
|
+
process.stderr.write(`[bridge] model: ${model ?? 'unknown'} apiKeySource: ${keySource ?? 'none'}\n`);
|
|
371
295
|
}
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
// First conversation since bridge started — announce prominently
|
|
387
|
-
// so the user can confirm which model and credential is in use.
|
|
388
|
-
const source = keySource === 'none' || !keySource ? 'claude.ai subscription' : keySource;
|
|
389
|
-
const pin = (0, config_1.getBridgeModel)() ? ' (selected at startup)' : '';
|
|
390
|
-
process.stdout.write(`\n model: ${model ?? 'unknown'}${pin}\n auth: ${source}\n\n`);
|
|
391
|
-
modelAnnounced = true;
|
|
392
|
-
}
|
|
393
|
-
else {
|
|
394
|
-
// Subsequent conversations — quiet line for power users.
|
|
395
|
-
process.stderr.write(`[bridge] model: ${model ?? 'unknown'} apiKeySource: ${keySource ?? 'none'}\n`);
|
|
396
|
-
}
|
|
397
|
-
sessionIdExtracted = true;
|
|
296
|
+
sessionIdExtracted = true;
|
|
297
|
+
}
|
|
298
|
+
if (type === 'assistant') {
|
|
299
|
+
messageCount++;
|
|
300
|
+
const msg = event['message'];
|
|
301
|
+
const u = msg?.['usage'];
|
|
302
|
+
if (u) {
|
|
303
|
+
usage = {
|
|
304
|
+
input_tokens: (usage?.input_tokens ?? 0) + (u['input_tokens'] ?? 0),
|
|
305
|
+
output_tokens: (usage?.output_tokens ?? 0) + (u['output_tokens'] ?? 0),
|
|
306
|
+
cache_read_input_tokens: (usage?.cache_read_input_tokens ?? 0) + (u['cache_read_input_tokens'] ?? 0),
|
|
307
|
+
cache_creation_input_tokens: (usage?.cache_creation_input_tokens ?? 0) + (u['cache_creation_input_tokens'] ?? 0),
|
|
308
|
+
};
|
|
309
|
+
lastContextTokens = (u['input_tokens'] ?? 0) + (u['cache_read_input_tokens'] ?? 0) + (u['cache_creation_input_tokens'] ?? 0);
|
|
398
310
|
}
|
|
399
|
-
|
|
400
|
-
if (
|
|
401
|
-
|
|
402
|
-
const
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
if (block['type'] === 'tool_use') {
|
|
417
|
-
producedRealOutput = true;
|
|
418
|
-
const toolName = block['name'];
|
|
419
|
-
const toolId = block['id'];
|
|
420
|
-
if (toolId)
|
|
421
|
-
toolNames.set(toolId, toolName);
|
|
422
|
-
if (debug) {
|
|
423
|
-
// Clean transcript: a single coloured block with the full input.
|
|
424
|
-
debugBlock(`tool → ${toolName}`, exports.SECTION_COLORS.input, formatPayload(block['input']));
|
|
425
|
-
}
|
|
426
|
-
else {
|
|
427
|
-
if (wroteText) {
|
|
428
|
-
process.stderr.write('\n');
|
|
429
|
-
wroteText = false;
|
|
430
|
-
}
|
|
431
|
-
const prefix = toolName.startsWith('mcp__') ? '[mcp]' : '[tool]';
|
|
432
|
-
process.stderr.write(paint(exports.SECTION_COLORS.input, `[bridge] ${prefix} ${toolName}`) + '\n');
|
|
433
|
-
if (verbose) {
|
|
434
|
-
const input = block['input'];
|
|
435
|
-
process.stderr.write(paint(exports.SECTION_COLORS.input, `[bridge:verbose] ─── input ${toolName} ───\n${formatPayload(input)}\n[bridge:verbose] ─── end input ───`) + '\n');
|
|
436
|
-
}
|
|
311
|
+
const content = msg?.['content'];
|
|
312
|
+
if (Array.isArray(content)) {
|
|
313
|
+
let wroteText = false;
|
|
314
|
+
for (const block of content) {
|
|
315
|
+
if (block['type'] === 'tool_use') {
|
|
316
|
+
producedRealOutput = true;
|
|
317
|
+
const toolName = block['name'];
|
|
318
|
+
const toolId = block['id'];
|
|
319
|
+
if (toolId)
|
|
320
|
+
toolNames.set(toolId, toolName);
|
|
321
|
+
if (debug) {
|
|
322
|
+
debugBlock(`tool → ${toolName}`, SECTION_COLORS.input, formatPayload(block['input']));
|
|
323
|
+
}
|
|
324
|
+
else {
|
|
325
|
+
if (wroteText) {
|
|
326
|
+
process.stderr.write('\n');
|
|
327
|
+
wroteText = false;
|
|
437
328
|
}
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
//
|
|
443
|
-
// Valid forms:
|
|
444
|
-
// mcp__1presence__<name> — namespaced MCP form
|
|
445
|
-
// <snake_case_name> — bare form; Claude Code may omit the prefix in
|
|
446
|
-
// stream-json output. Safe because --strict-mcp-config
|
|
447
|
-
// limits MCP to the 1presence server only.
|
|
448
|
-
// Invalid (real violations):
|
|
449
|
-
// PascalCase (Bash, Read, Write, …) — Claude Code built-ins
|
|
450
|
-
// mcp__<other>__* — tools from a different MCP server
|
|
451
|
-
const isMcp1presence = toolName.startsWith('mcp__1presence__');
|
|
452
|
-
const isBareName = /^[a-z][a-z0-9_]*$/.test(toolName);
|
|
453
|
-
if (!isMcp1presence && !isBareName) {
|
|
454
|
-
killedForViolation = true;
|
|
455
|
-
const violation = `bridge tool violation: ${toolName} is not allowed in Local Mode`;
|
|
456
|
-
process.stderr.write(`[bridge] FATAL ${violation} — killing\n`);
|
|
457
|
-
active.delete(conversationId);
|
|
458
|
-
proc.kill('SIGKILL');
|
|
459
|
-
onError(violation, usage, extractedModel);
|
|
460
|
-
return;
|
|
329
|
+
const prefix = toolName.startsWith('mcp__') ? '[mcp]' : '[tool]';
|
|
330
|
+
process.stderr.write(paint(SECTION_COLORS.input, `[bridge] ${prefix} ${toolName}`) + '\n');
|
|
331
|
+
if (verbose) {
|
|
332
|
+
process.stderr.write(paint(SECTION_COLORS.input, `[bridge:verbose] ─── input ${toolName} ───\n${formatPayload(block['input'])}\n[bridge:verbose] ─── end input ───`) + '\n');
|
|
461
333
|
}
|
|
462
334
|
}
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
}
|
|
477
|
-
else {
|
|
478
|
-
producedRealOutput = true;
|
|
479
|
-
if (debug) {
|
|
480
|
-
// Full text, newlines intact — the readable transcript.
|
|
481
|
-
debugBlock('assistant', exports.SECTION_COLORS.assistant, text);
|
|
482
|
-
}
|
|
483
|
-
else {
|
|
484
|
-
process.stderr.write(paint(exports.SECTION_COLORS.assistant, text.replace(/\n+/g, ' ')));
|
|
485
|
-
wroteText = true;
|
|
486
|
-
}
|
|
487
|
-
}
|
|
488
|
-
}
|
|
335
|
+
// Defense-in-depth: canUseTool + --tools "" + strictMcpConfig should
|
|
336
|
+
// make a non-1Presence tool unreachable. If one appears anyway, kill
|
|
337
|
+
// the turn so any side effect in flight is the only damage done.
|
|
338
|
+
const isMcp1presence = toolName.startsWith('mcp__1presence__');
|
|
339
|
+
const isBareName = /^[a-z][a-z0-9_]*$/.test(toolName);
|
|
340
|
+
if (!isMcp1presence && !isBareName) {
|
|
341
|
+
killedForViolation = true;
|
|
342
|
+
const violation = `bridge tool violation: ${toolName} is not allowed in Local Mode`;
|
|
343
|
+
process.stderr.write(`[bridge] FATAL ${violation} — aborting\n`);
|
|
344
|
+
active.delete(conversationId);
|
|
345
|
+
abort.abort();
|
|
346
|
+
onError(violation, usage, extractedModel);
|
|
347
|
+
return false;
|
|
489
348
|
}
|
|
490
349
|
}
|
|
491
|
-
if (
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
350
|
+
else if (block['type'] === 'text') {
|
|
351
|
+
const blockText = block['text'];
|
|
352
|
+
if (blockText) {
|
|
353
|
+
// The CLI/SDK can report auth/API failures as a synthetic assistant
|
|
354
|
+
// text turn whose wording varies. Detect by the structured signal
|
|
355
|
+
// (event.error) plus a wording fallback, so it's classified rather
|
|
356
|
+
// than leaking raw into the chat as if the model had said it.
|
|
357
|
+
const isSynthetic = msg?.['model'] === '<synthetic>';
|
|
358
|
+
const isAuthFailure = event['error'] === 'authentication_failed' ||
|
|
359
|
+
(isSynthetic && /(api error:\s*40[13]\b|invalid (api key|authentication)|please run \/login|failed to authenticate|unauthor)/i.test(blockText));
|
|
360
|
+
if (/^API Error:/i.test(blockText.trimStart()) || isAuthFailure) {
|
|
361
|
+
sawApiError = true;
|
|
362
|
+
apiErrorText = blockText.trim();
|
|
363
|
+
if (isAuthFailure)
|
|
364
|
+
sawAuthFailure = true;
|
|
365
|
+
process.stderr.write(paint(SECTION_COLORS.result, `[bridge] ${blockText.replace(/\n+/g, ' ')}`) + '\n');
|
|
366
|
+
return false; // suppress — never forward a raw error turn
|
|
367
|
+
}
|
|
368
|
+
producedRealOutput = true;
|
|
504
369
|
if (debug) {
|
|
505
|
-
|
|
506
|
-
const errFlag = block['is_error'] ? ' [error]' : '';
|
|
507
|
-
debugBlock(`result ← ${name}${errFlag}`, exports.SECTION_COLORS.result, formatPayload(out));
|
|
370
|
+
debugBlock('assistant', SECTION_COLORS.assistant, blockText);
|
|
508
371
|
}
|
|
509
372
|
else {
|
|
510
|
-
process.stderr.write(paint(
|
|
373
|
+
process.stderr.write(paint(SECTION_COLORS.assistant, blockText.replace(/\n+/g, ' ')));
|
|
374
|
+
wroteText = true;
|
|
511
375
|
}
|
|
512
376
|
}
|
|
513
377
|
}
|
|
514
378
|
}
|
|
379
|
+
if (wroteText)
|
|
380
|
+
process.stderr.write('\n');
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
if ((verbose || debug) && type === 'user') {
|
|
384
|
+
const msg = event['message'];
|
|
385
|
+
const content = msg?.['content'];
|
|
386
|
+
if (Array.isArray(content)) {
|
|
387
|
+
for (const block of content) {
|
|
388
|
+
if (block['type'] === 'tool_result') {
|
|
389
|
+
const id = block['tool_use_id'] ?? '';
|
|
390
|
+
const out = block['content'];
|
|
391
|
+
if (debug) {
|
|
392
|
+
const name = toolNames.get(id) ?? id ?? 'result';
|
|
393
|
+
const errFlag = block['is_error'] ? ' [error]' : '';
|
|
394
|
+
debugBlock(`result ← ${name}${errFlag}`, SECTION_COLORS.result, formatPayload(out));
|
|
395
|
+
}
|
|
396
|
+
else {
|
|
397
|
+
process.stderr.write(paint(SECTION_COLORS.result, `[bridge:verbose] ─── output ${id} ───\n${formatPayload(out)}\n[bridge:verbose] ─── end output ───`) + '\n');
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
}
|
|
515
401
|
}
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
402
|
+
}
|
|
403
|
+
if (type === 'result') {
|
|
404
|
+
// total_cost_usd is the SDK's notional figure (0 on the no-op history
|
|
405
|
+
// append cycle; the real number on the live turn). Keep the largest seen.
|
|
406
|
+
const c = event['total_cost_usd'] ?? event['cost_usd'];
|
|
407
|
+
if (typeof c === 'number' && c > costUsd)
|
|
408
|
+
costUsd = c;
|
|
409
|
+
if (event['is_error'] === true) {
|
|
410
|
+
sawApiError = true;
|
|
411
|
+
const status = event['api_error_status'];
|
|
412
|
+
if (status === 401 || status === 403)
|
|
413
|
+
sawAuthFailure = true;
|
|
414
|
+
if (!apiErrorText && typeof event['result'] === 'string') {
|
|
415
|
+
apiErrorText = event['result'].trim();
|
|
416
|
+
}
|
|
521
417
|
}
|
|
522
|
-
if (!suppressEvent)
|
|
523
|
-
onEvent(event);
|
|
524
418
|
}
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
419
|
+
return true;
|
|
420
|
+
}
|
|
421
|
+
// Drive the turn. Synchronous spawnClaude returns immediately; the SDK loop
|
|
422
|
+
// runs in this async IIFE and fires the same callbacks the CLI path did.
|
|
423
|
+
void (async () => {
|
|
424
|
+
let systemPrompt;
|
|
425
|
+
let mcpServers;
|
|
426
|
+
try {
|
|
427
|
+
systemPrompt = readFileSync(systemPromptPath, 'utf-8');
|
|
428
|
+
const mcpRaw = JSON.parse(readFileSync(mcpConfigPath, 'utf-8'));
|
|
429
|
+
mcpServers = mcpRaw.mcpServers ?? {};
|
|
430
|
+
}
|
|
431
|
+
catch (err) {
|
|
432
|
+
active.delete(conversationId);
|
|
433
|
+
onError(`Local Mode setup files unavailable: ${err.message}`, null, null);
|
|
536
434
|
return;
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
435
|
+
}
|
|
436
|
+
const options = {
|
|
437
|
+
systemPrompt, // custom string → replaces the default Claude Code prompt
|
|
438
|
+
mcpServers: mcpServers,
|
|
439
|
+
strictMcpConfig: true, // only our MCP server, ignore project/user/plugin MCP
|
|
440
|
+
settingSources: [], // no user/project settings or memory
|
|
441
|
+
allowedTools: ['mcp__1presence__*'], // auto-approve our MCP surface (no prompt)
|
|
442
|
+
canUseTool, // hard deny anything else
|
|
443
|
+
extraArgs: { tools: '' }, // disable ALL built-in tools (= CLI --tools "")
|
|
444
|
+
cwd: BRIDGE_CWD,
|
|
445
|
+
abortController: abort,
|
|
446
|
+
includePartialMessages: false, // whole messages, matching the old non-partial path
|
|
447
|
+
permissionMode: 'default',
|
|
448
|
+
env: safeEnv,
|
|
449
|
+
stderr: (line) => { if (verbose && line.trim())
|
|
450
|
+
process.stderr.write(`[claude] ${line.trim()}\n`); },
|
|
451
|
+
...(pinnedModel ? { model: pinnedModel } : {}),
|
|
452
|
+
};
|
|
453
|
+
const promptMessages = buildPromptMessages(history);
|
|
454
|
+
try {
|
|
455
|
+
for await (const m of query({ prompt: promptStream(promptMessages), options })) {
|
|
456
|
+
// Skip echoed input replays — they would double-count in the accumulator
|
|
457
|
+
// and re-stream prior turns to the PWA.
|
|
458
|
+
if (m.isReplay)
|
|
459
|
+
continue;
|
|
460
|
+
switch (m.type) {
|
|
461
|
+
case 'system': {
|
|
462
|
+
if (m.subtype === 'init') {
|
|
463
|
+
const init = m;
|
|
464
|
+
const event = { type: 'system', subtype: 'init', model: init.model, apiKeySource: init.apiKeySource };
|
|
465
|
+
if (handleEvent(event))
|
|
466
|
+
onEvent(event);
|
|
467
|
+
}
|
|
468
|
+
break;
|
|
469
|
+
}
|
|
470
|
+
case 'assistant': {
|
|
471
|
+
const am = m;
|
|
472
|
+
// Structured error signal — classify, do not forward as a real turn.
|
|
473
|
+
if (am.error) {
|
|
474
|
+
sawApiError = true;
|
|
475
|
+
if (am.error === 'authentication_failed' || am.error === 'oauth_org_not_allowed')
|
|
476
|
+
sawAuthFailure = true;
|
|
477
|
+
if (!apiErrorText)
|
|
478
|
+
apiErrorText = `API Error: ${am.error}`;
|
|
479
|
+
process.stderr.write(paint(SECTION_COLORS.result, `[bridge] assistant error: ${am.error}`) + '\n');
|
|
480
|
+
break;
|
|
481
|
+
}
|
|
482
|
+
const event = { type: 'assistant', message: am.message, error: am.error };
|
|
483
|
+
if (handleEvent(event))
|
|
484
|
+
onEvent(event);
|
|
485
|
+
if (killedForViolation)
|
|
486
|
+
return; // handleEvent already aborted + onError
|
|
487
|
+
break;
|
|
488
|
+
}
|
|
489
|
+
case 'user': {
|
|
490
|
+
const um = m;
|
|
491
|
+
const event = { type: 'user', message: um.message };
|
|
492
|
+
if (handleEvent(event))
|
|
493
|
+
onEvent(event);
|
|
494
|
+
break;
|
|
495
|
+
}
|
|
496
|
+
case 'result': {
|
|
497
|
+
const rm = m;
|
|
498
|
+
const event = {
|
|
499
|
+
type: 'result',
|
|
500
|
+
subtype: rm['subtype'],
|
|
501
|
+
total_cost_usd: rm['total_cost_usd'],
|
|
502
|
+
is_error: rm['is_error'],
|
|
503
|
+
api_error_status: rm['api_error_status'],
|
|
504
|
+
result: rm['result'],
|
|
505
|
+
};
|
|
506
|
+
if (handleEvent(event))
|
|
507
|
+
onEvent(event);
|
|
508
|
+
break;
|
|
509
|
+
}
|
|
510
|
+
case 'auth_status': {
|
|
511
|
+
const as = m;
|
|
512
|
+
if (as.error) {
|
|
513
|
+
sawApiError = true;
|
|
514
|
+
sawAuthFailure = true;
|
|
515
|
+
apiErrorText = as.error;
|
|
516
|
+
}
|
|
517
|
+
break;
|
|
518
|
+
}
|
|
519
|
+
case 'rate_limit_event': {
|
|
520
|
+
// SDK surfaces upstream rate-limit pauses; it retries internally.
|
|
521
|
+
// Admin-only ephemeral notice — jargon is fine in Local Mode.
|
|
522
|
+
onNotice?.('Claude Code is pausing briefly for an upstream rate limit, then continuing…');
|
|
523
|
+
break;
|
|
524
|
+
}
|
|
525
|
+
// Everything else (partial messages, status, hooks, notifications,
|
|
526
|
+
// thinking tokens, …) is SDK-internal — not part of the CLI event
|
|
527
|
+
// contract the gateway/accumulator understand, so it is dropped.
|
|
528
|
+
default:
|
|
529
|
+
break;
|
|
530
|
+
}
|
|
541
531
|
}
|
|
542
|
-
catch { /* ignore */ }
|
|
543
532
|
}
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
//
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
const elapsed = Date.now() - firstAttemptAt;
|
|
551
|
-
if (attemptIdx < MAX_TURN_RETRIES && sawApiError && !producedRealOutput && elapsed < RETRY_WALL_CLOCK_CAP_MS) {
|
|
552
|
-
const delay = RETRY_BACKOFF_BASE_MS * (attemptIdx + 1);
|
|
553
|
-
const nextAttempt = attemptIdx + 2;
|
|
554
|
-
process.stderr.write(`[bridge] turn failed before output (${apiErrorText.replace(/\n+/g, ' ').slice(0, 120)}) — retrying (${nextAttempt} of ${MAX_TURN_RETRIES + 1}) in ${delay}ms\n`);
|
|
555
|
-
// Admin-only ephemeral thread notice — jargon is fine in Local Mode.
|
|
556
|
-
onNotice?.(`Claude Code print-mode 400 (tool-use concurrency, anthropics/claude-code#18131) — respawning, attempt ${nextAttempt}/${MAX_TURN_RETRIES + 1}…`);
|
|
557
|
-
const timer = setTimeout(() => {
|
|
558
|
-
pendingRetries.delete(conversationId);
|
|
559
|
-
spawnClaude({ ...params, _attemptIdx: attemptIdx + 1, _firstAttemptAt: firstAttemptAt });
|
|
560
|
-
}, delay);
|
|
561
|
-
pendingRetries.set(conversationId, timer);
|
|
533
|
+
catch (err) {
|
|
534
|
+
active.delete(conversationId);
|
|
535
|
+
// Aborted by supersede or the Stop button — no error to surface.
|
|
536
|
+
if (abort.signal.aborted)
|
|
537
|
+
return;
|
|
538
|
+
if (killedForViolation)
|
|
562
539
|
return;
|
|
540
|
+
const message = err?.message ?? String(err);
|
|
541
|
+
if (/40[13]\b|unauthor|invalid (api key|authentication)|please run \/login/i.test(message)) {
|
|
542
|
+
sawAuthFailure = true;
|
|
563
543
|
}
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
}
|
|
569
|
-
else {
|
|
570
|
-
onDone(messageCount, costUsd, usage, extractedModel);
|
|
544
|
+
if (!apiErrorText)
|
|
545
|
+
apiErrorText = message;
|
|
546
|
+
onError(describeCliFailure(apiErrorText, sawAuthFailure), usage, extractedModel);
|
|
547
|
+
return;
|
|
571
548
|
}
|
|
572
|
-
});
|
|
573
|
-
proc.on('error', (err) => {
|
|
574
549
|
active.delete(conversationId);
|
|
575
|
-
if (
|
|
576
|
-
|
|
550
|
+
if (killedForViolation)
|
|
551
|
+
return; // already errored
|
|
552
|
+
if (abort.signal.aborted)
|
|
553
|
+
return; // superseded/cancelled mid-stream
|
|
554
|
+
if (sawAuthFailure || (sawApiError && !producedRealOutput)) {
|
|
555
|
+
onError(describeCliFailure(apiErrorText, sawAuthFailure), usage, extractedModel);
|
|
577
556
|
}
|
|
578
557
|
else {
|
|
579
|
-
|
|
558
|
+
onDone(messageCount, costUsd, usage, extractedModel, lastContextTokens);
|
|
580
559
|
}
|
|
581
|
-
});
|
|
560
|
+
})();
|
|
582
561
|
}
|
|
583
|
-
function killAll() {
|
|
584
|
-
for (const [,
|
|
585
|
-
|
|
562
|
+
export function killAll() {
|
|
563
|
+
for (const [, abort] of active) {
|
|
564
|
+
abort.abort();
|
|
586
565
|
}
|
|
587
566
|
active.clear();
|
|
588
567
|
}
|
|
589
568
|
/**
|
|
590
569
|
* Stop one in-flight turn (the Stop button, relayed by the gateway as a
|
|
591
|
-
* `cancel` frame).
|
|
592
|
-
*
|
|
593
|
-
*
|
|
594
|
-
*
|
|
570
|
+
* `cancel` frame). Aborts the running query() for this conversation so no
|
|
571
|
+
* further stream events are produced. Mirrors the supersede path in spawnClaude.
|
|
572
|
+
* Returns true if something was actually stopped. Mechanical only — no product
|
|
573
|
+
* logic lives here.
|
|
595
574
|
*/
|
|
596
|
-
function cancelConversation(conversationId) {
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
proc.kill('SIGTERM');
|
|
575
|
+
export function cancelConversation(conversationId) {
|
|
576
|
+
const abort = active.get(conversationId);
|
|
577
|
+
if (abort) {
|
|
578
|
+
abort.abort();
|
|
601
579
|
active.delete(conversationId);
|
|
602
|
-
|
|
603
|
-
}
|
|
604
|
-
const pending = pendingRetries.get(conversationId);
|
|
605
|
-
if (pending) {
|
|
606
|
-
clearTimeout(pending);
|
|
607
|
-
pendingRetries.delete(conversationId);
|
|
608
|
-
stopped = true;
|
|
580
|
+
return true;
|
|
609
581
|
}
|
|
610
|
-
return
|
|
582
|
+
return false;
|
|
611
583
|
}
|