@chatpanel/bridge 0.10.40 → 0.10.42

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chatpanel/bridge",
3
- "version": "0.10.40",
3
+ "version": "0.10.42",
4
4
  "type": "module",
5
5
  "description": "Local bridge that exposes the AI coding agents installed on your machine \u2014 Claude Code (CLI), Codex (CLI), and Antigravity CLI (formerly Gemini CLI, which remains available for business/enterprise) \u2014 to the ChatPanel Chrome extension over a localhost SSE endpoint. Bring your own agent.",
6
6
  "keywords": [
package/src/api-compat.js CHANGED
@@ -6,6 +6,7 @@
6
6
  // are rejected when accepting them would produce misleading behavior.
7
7
 
8
8
  import { randomUUID } from 'node:crypto';
9
+ import { normalizeNames } from './mcp-quarantine.js';
9
10
 
10
11
  const AGENT_IDS = new Set(['claude', 'codex', 'antigravity', 'pi', 'opencode', 'kiro', 'copilot', 'deepseek']);
11
12
 
@@ -76,6 +77,9 @@ function resolveTarget(model, chatpanel = {}) {
76
77
  workingDir: chatpanel.working_dir || chatpanel.workingDir || process.env.CHATPANEL_API_WORKING_DIR || '',
77
78
  permissionMode,
78
79
  useLocalConfig: chatpanel.use_local_config ?? chatpanel.useLocalConfig ?? true,
80
+ // Servers in the agent's OWN MCP config to leave out of this run — one that can't
81
+ // authenticate or can't be reached otherwise kills the whole turn (see mcp-quarantine.js).
82
+ mcpDisabled: normalizeNames(chatpanel.mcp_disabled ?? chatpanel.mcpDisabled),
79
83
  ...(engineModel ? { model: engineModel } : {}),
80
84
  },
81
85
  };
@@ -0,0 +1,128 @@
1
+ // Turning a CLI agent's dying breath into something a person can act on.
2
+ //
3
+ // When an agent exits non-zero we used to surface its raw stderr. For a crash that is fine;
4
+ // for the common real failure — one of the AGENT'S OWN MCP servers refusing to authenticate —
5
+ // it is not: those servers answer with an HTML error page, so the user got kilobytes of
6
+ // markup, inline CSS and an SVG logo in the chat, with the one useful sentence buried inside.
7
+ //
8
+ // The failure is also usually not ChatPanel's to fix. Saying whose it is, and naming the
9
+ // server, is the difference between "something broke" and "re-auth that server".
10
+ //
11
+ // `mcpFailure` is the machine-readable half of the same knowledge: mcp-quarantine.js reads
12
+ // it to DROP the offending server and run again, so the common cases never reach a human at
13
+ // all. One set of patterns serves both — a second copy would drift.
14
+
15
+ const NOISE = [
16
+ /^\s*$/,
17
+ /^\s*at\s+/, // stack frames
18
+ /^\s*[.#]?[\w-]+\s*\{/, // CSS rules
19
+ /^\s*[\w-]+:\s*[^;]+;$/,// CSS declarations
20
+ /^\s*<\//, // closing tags
21
+ ];
22
+
23
+ // Strip HTML/CSS/SVG so an error page collapses to whatever prose it contained.
24
+ export function stripMarkup(text) {
25
+ return String(text || '')
26
+ .replace(/<script[\s\S]*?<\/script>/gi, ' ')
27
+ .replace(/<style[\s\S]*?<\/style>/gi, ' ')
28
+ .replace(/<svg[\s\S]*?<\/svg>/gi, ' ')
29
+ .replace(/<[^>]+>/g, ' ')
30
+ .replace(/&nbsp;/g, ' ')
31
+ .replace(/[ \t]+/g, ' ');
32
+ }
33
+
34
+ // Which server the output blames, if it names one. Codex and Claude Code phrase this several
35
+ // ways, so try each; a name we cannot read means there is nothing to disable, which is why
36
+ // every caller must tolerate `null`.
37
+ const SERVER_PATTERNS = [
38
+ /(?:refresh OAuth tokens|refresh tools) for (?:MCP )?server ['"`]?([\w.-]+)['"`]?/i,
39
+ /MCP server ['"`]([\w.-]+)['"`]/i,
40
+ /server ['"]?([\w.-]+)['"]? (?:requires|failed) auth/i,
41
+ ];
42
+ export function mcpServerName(text) {
43
+ for (const re of SERVER_PATTERNS) {
44
+ const name = re.exec(String(text || ''))?.[1];
45
+ if (name) return name;
46
+ }
47
+ return null;
48
+ }
49
+
50
+ // The named MCP causes worth translating. `say` is the sentence for a human; `short` is the
51
+ // clause used when we skip the server and carry on. Order matters — specific before generic.
52
+ const MCP_CAUSES = [
53
+ {
54
+ kind: 'expired',
55
+ test: /refresh token (?:does not exist|was rejected)|invalid_grant/i,
56
+ short: 'its saved login has expired',
57
+ say: (who) => `${who} needs re-authentication — its saved OAuth token has expired or been revoked. Re-login to that server in the agent's own config; ChatPanel can't refresh it.`,
58
+ },
59
+ {
60
+ kind: 'unauthorized',
61
+ test: /invalid_token|AuthRequired|www-authenticate|\b401\b/i,
62
+ short: 'it rejected the agent\'s token',
63
+ say: (who) => `${who} rejected the agent's token (401/invalid_token). Re-authenticate that server in the agent, then retry.`,
64
+ },
65
+ {
66
+ kind: 'refused',
67
+ test: /HTTP 403|\b403\b/,
68
+ short: 'it is refusing requests',
69
+ say: (who) => `${who} returned 403 and an error page — that server is refusing requests or is temporarily down. This is outside ChatPanel; retry when it recovers.`,
70
+ },
71
+ {
72
+ // The off-network case: a VPN-only server seen from a cafe. Only counted when the text
73
+ // is talking about MCP at all, so an ordinary provider timeout is not misread as this.
74
+ kind: 'unreachable',
75
+ requiresMcpContext: true,
76
+ test: /handshaking with MCP server failed|ECONNREFUSED|ENOTFOUND|EAI_AGAIN|dns error|connection (?:refused|reset)|failed to start and is unavailable|network is unreachable/i,
77
+ short: 'it could not be reached',
78
+ say: (who) => `${who} could not be reached — it is offline, or needs a network (VPN) this machine is not on. ChatPanel can't reach it either; retry when it is available.`,
79
+ },
80
+ ];
81
+
82
+ /**
83
+ * Classify an MCP-server failure in an agent's output.
84
+ * @returns {{server: string|null, kind: string, short: string, say: string}|null}
85
+ */
86
+ export function mcpFailure(text) {
87
+ const raw = String(text || '');
88
+ const server = mcpServerName(raw);
89
+ const hasMcpContext = /\bmcp\b/i.test(raw) || !!server;
90
+ for (const cause of MCP_CAUSES) {
91
+ if (cause.requiresMcpContext && !hasMcpContext) continue;
92
+ if (!cause.test.test(raw)) continue;
93
+ return {
94
+ server,
95
+ kind: cause.kind,
96
+ short: cause.short,
97
+ say: cause.say(server ? `its MCP server "${server}"` : 'one of its MCP servers'),
98
+ };
99
+ }
100
+ return null;
101
+ }
102
+
103
+ // The named causes worth translating. Each returns a sentence that says what to DO.
104
+ function knownCause(text) {
105
+ const mcp = mcpFailure(text);
106
+ if (mcp) return mcp.say;
107
+ if (/ENOENT|command not found/i.test(text)) return 'the command could not be found on PATH.';
108
+ return null;
109
+ }
110
+
111
+ /**
112
+ * A short, actionable summary of why a CLI agent exited. Never returns markup, and never more
113
+ * than a couple of lines — the full output stays in the bridge log for anyone debugging.
114
+ */
115
+ export function summarizeCliError(label, code, stderr, stdout = '') {
116
+ const raw = `${stderr || ''}\n${stdout || ''}`;
117
+ const cause = knownCause(raw);
118
+ if (cause) return `${label} exited ${code}: ${cause}`;
119
+
120
+ const lines = stripMarkup(raw)
121
+ .split('\n')
122
+ .map((l) => l.trim())
123
+ .filter((l) => l && !NOISE.some((re) => re.test(l)));
124
+ // Prefer the last line that reads like an error, else the last line at all.
125
+ const errish = lines.filter((l) => /error|fail|fatal|panic|refused|denied|timeout/i.test(l));
126
+ const pick = (errish.length ? errish : lines).pop() || 'failed';
127
+ return `${label} exited ${code}: ${pick.length > 300 ? `${pick.slice(0, 300)}…` : pick}`;
128
+ }
@@ -16,6 +16,7 @@ import os from 'node:os';
16
16
  import path from 'node:path';
17
17
  import { findAgentBin } from '../env.js';
18
18
  import { buildCliPrompt } from './prompt.js';
19
+ import { summarizeCliError } from '../cli-errors.js';
19
20
  import { killOnAbort, spawnGroupOpts } from '../proc.js';
20
21
  import { pushExtraArgs, FORBIDDEN } from './args.js';
21
22
  import { resolveWorkdir } from '../workdir.js';
@@ -153,7 +154,7 @@ export async function chat({ messages, system, options, images }, emit, { signal
153
154
  emit({ type: 'done', text: '' });
154
155
  resolve();
155
156
  } else {
156
- reject(new Error(`Antigravity exited ${code}: ${err.trim() || out.trim() || 'failed'}`));
157
+ reject(new Error(summarizeCliError('Antigravity', code, err, out)));
157
158
  }
158
159
  });
159
160
  });
@@ -19,6 +19,7 @@ import os from 'node:os';
19
19
  import path from 'node:path';
20
20
  import { resolveClaude, buildSpawnSpec, isCompiledBinary, selfMcpStdio } from '../env.js';
21
21
  import { buildCliPrompt } from './prompt.js';
22
+ import { summarizeCliError } from '../cli-errors.js';
22
23
  import { killOnAbort } from '../proc.js';
23
24
  import { pushExtraArgs, FORBIDDEN } from './args.js';
24
25
  import { displayPath, resolveWorkdir } from '../workdir.js';
@@ -160,7 +161,7 @@ function runClaude({ prompt, args, cwd, emit, signal }) {
160
161
  detach();
161
162
  if (signal?.aborted) { resolve({ streamedAny, resultText }); return; } // Stop pressed — end quietly
162
163
  if (code === 0) resolve({ streamedAny, resultText });
163
- else reject(new Error(`Claude Code exited ${code}: ${stderr.trim().split('\n').pop() || 'failed'}`));
164
+ else reject(new Error(summarizeCliError('Claude Code', code, stderr)));
164
165
  });
165
166
 
166
167
  child.stdin.write(prompt);
@@ -24,6 +24,8 @@ import { findAgentBin, selfMcpStdio } from '../env.js';
24
24
  import { buildCliPrompt } from './prompt.js';
25
25
  import { pushExtraArgs, FORBIDDEN } from './args.js';
26
26
  import { resolveWorkdir } from '../workdir.js';
27
+ import { summarizeCliError } from '../cli-errors.js';
28
+ import { disabledMcpServers, planMcpRetry, quarantine } from '../mcp-quarantine.js';
27
29
 
28
30
  // Idle timeout: re-armed on every stdout/stderr chunk, so a long run that keeps
29
31
  // streaming never trips it — only true silence does. Override with
@@ -116,6 +118,40 @@ export function codexMcpConfigArgs(mcp) {
116
118
  return args;
117
119
  }
118
120
 
121
+ // Which MCP servers this Codex actually has configured.
122
+ //
123
+ // READ THIS BEFORE TOUCHING codexDisableArgs. `-c mcp_servers.X.enabled=false` for a name
124
+ // that is NOT already in config.toml does not disable anything — it DEFINES a new server
125
+ // that has no transport, and Codex then refuses to start at all:
126
+ // Error: failed to load bootstrap configuration
127
+ // Caused by: invalid transport in `mcp_servers.does_not_exist`
128
+ // So a stale quarantine entry, or a typo in the user's deny list, would break EVERY run —
129
+ // strictly worse than the failure this feature exists to fix. We therefore only ever disable
130
+ // a server we can see, and a name we cannot see is left alone (a no-op, never a break).
131
+ //
132
+ // Read from config.toml — instant, and the same source listModels() already reads — rather
133
+ // than shelling out to `codex mcp list --json`, which is authoritative but costs ~0.8s on
134
+ // every single turn.
135
+ export function configuredMcpServers(home) {
136
+ try {
137
+ const cfg = readFileSync(path.join(home, 'config.toml'), 'utf8');
138
+ const names = new Set();
139
+ for (const m of cfg.matchAll(/^\s*\[mcp_servers\.([A-Za-z0-9_-]+)\s*[.\]]/gm)) names.add(m[1]);
140
+ return names;
141
+ } catch {
142
+ return new Set(); // no config (or the isolated home) — there is nothing to disable
143
+ }
144
+ }
145
+
146
+ // Turn off servers from the user's own config.toml for THIS RUN only — never by editing
147
+ // their file. Names are validated upstream (mcp-quarantine.js) and filtered against
148
+ // configuredMcpServers by the caller, which is what makes it safe to interpolate one into a
149
+ // `-c` override key; `-c` is otherwise blocked in extraArgs precisely because it reaches
150
+ // config that matters.
151
+ export function codexDisableArgs(names = []) {
152
+ return names.flatMap((name) => ['-c', `mcp_servers.${name}.enabled=false`]);
153
+ }
154
+
119
155
  // Write base64 data-URL images to temp files so `codex exec -i <file>` can
120
156
  // attach them to the prompt as vision input. Returns the paths (caller cleans up).
121
157
  async function writeImages(images, tag) {
@@ -131,54 +167,15 @@ async function writeImages(images, tag) {
131
167
  return files;
132
168
  }
133
169
 
134
- export async function chat({ messages, system, options, images }, emit, { signal } = {}) {
135
- const tag = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
136
- const outFile = path.join(os.tmpdir(), `chatpanel-codex-${tag}.txt`);
137
- const imageFiles = await writeImages(images, tag);
138
- const cleanupImages = () => imageFiles.forEach((f) => unlink(f).catch(() => {}));
139
-
140
- const cwd = resolveWorkdir(options.workingDir);
141
- const args = ['exec', '--json', '--skip-git-repo-check', '-o', outFile];
142
- // Headless exec has no human to approve actions. With MCP/browser tools armed
143
- // Codex would otherwise raise an approval prompt it can't show — and cancel the
144
- // tool call. So in bypassPermissions (full autonomy, what "Act on page" needs)
145
- // use the all-in bypass flag, which also clears MCP-tool approval. Lower modes
146
- // keep the sandbox + never-ask, which auto-runs within bounds.
147
- if (options.permissionMode === 'bypassPermissions') {
148
- args.push('--dangerously-bypass-approvals-and-sandbox');
149
- } else {
150
- const sandbox = options.permissionMode === 'acceptEdits' ? 'workspace-write' : 'read-only';
151
- args.push('-s', sandbox, '-c', 'approval_policy=never');
152
- }
153
- if (REASONING) args.push('-c', `model_reasoning_effort=${REASONING}`);
154
- // Ask Codex to emit reasoning SUMMARIES so the panel can stream the model's thinking.
155
- // Additive + safe: a no-op for models/providers that don't produce summaries (e.g. some
156
- // hosted models expose none), and cheap at the default effort. forwardEvent renders them.
157
- args.push('-c', 'model_reasoning_summary=auto');
158
- // Browser tools: register the bridge's MCP server as a stdio MCP server (the
159
- // bridge binary in --mcp-stdio mode), so Codex can call our page-action tools.
160
- // `-c key=value` parses value as TOML; JSON.stringify yields valid TOML here.
161
- args.push(...codexMcpConfigArgs(options.mcp));
162
- if (options.model) args.push('-m', options.model);
163
- // Drop caller extras that would re-open the sandbox/approval boundary (shared sanitizer).
164
- pushExtraArgs(args, options.extraArgs, FORBIDDEN.codex, emit);
165
- for (const f of imageFiles) args.push('-i', f); // attach images to the initial prompt
166
- args.push('-');
167
-
168
- // Default: use the user's skills/config. Opt-out → isolated home.
169
- const useLocal = options.useLocalConfig !== false;
170
- const env = { ...process.env };
171
- if (!useLocal) {
172
- const home = ensureIsolatedHome();
173
- if (home) env.CODEX_HOME = home;
174
- }
175
-
176
- await new Promise((resolve, reject) => {
170
+ // A single `codex exec` attempt. Resolves with how it ended rather than emitting the final
171
+ // message itself, so the caller can decide whether the run is worth ATTEMPTING AGAIN — the
172
+ // terminal delta/done belongs to whichever attempt actually answered.
173
+ function runCodex({ args, cwd, env, prompt, outFile, emit, signal }) {
174
+ return new Promise((resolve, reject) => {
177
175
  let child;
178
176
  try {
179
177
  child = spawn('codex', args, { cwd, stdio: ['pipe', 'pipe', 'pipe'], env, ...spawnGroupOpts });
180
178
  } catch (e) {
181
- cleanupImages();
182
179
  return reject(new Error(`Failed to start codex: ${e.message}`));
183
180
  }
184
181
 
@@ -186,6 +183,13 @@ export async function chat({ messages, system, options, images }, emit, { signal
186
183
 
187
184
  let stdout = '';
188
185
  let stderr = '';
186
+ // Retrying is only honest while nothing the user can see has been produced. Status lines
187
+ // are ours and are replaceable; a delta, a tool call or a reasoning summary is not.
188
+ let streamed = false;
189
+ const relay = (ev) => {
190
+ if (ev?.type !== 'status') streamed = true;
191
+ emit(ev);
192
+ };
189
193
  // Per-run event state: correlate a command's started/completed events and emit each
190
194
  // reasoning summary once (Codex sends the same item id across item.started/updated/completed).
191
195
  const evState = { started: new Set(), reasoned: new Set(), n: 0 };
@@ -208,7 +212,7 @@ export async function chat({ messages, system, options, images }, emit, { signal
208
212
  stdout = stdout.slice(nl + 1);
209
213
  if (!line.startsWith('{')) continue;
210
214
  try {
211
- forwardEvent(JSON.parse(line), emit, evState);
215
+ forwardEvent(JSON.parse(line), relay, evState);
212
216
  } catch {
213
217
  /* not a JSON event line */
214
218
  }
@@ -218,7 +222,6 @@ export async function chat({ messages, system, options, images }, emit, { signal
218
222
  child.on('error', (e) => {
219
223
  clearTimeout(idleTimer);
220
224
  detach();
221
- cleanupImages();
222
225
  reject(e);
223
226
  });
224
227
  child.on('close', async (code) => {
@@ -231,22 +234,98 @@ export async function chat({ messages, system, options, images }, emit, { signal
231
234
  /* no message file */
232
235
  }
233
236
  unlink(outFile).catch(() => {});
234
- cleanupImages();
235
- if (signal?.aborted) { resolve(); return; } // Stop pressed — end quietly, no error
236
- if (code === 0) {
237
- emit({ type: 'delta', text: text || '(no output)' });
238
- emit({ type: 'done', text: '' });
239
- resolve();
240
- } else {
241
- reject(new Error(`Codex exited ${code}: ${stderr.trim() || 'failed'}`));
242
- }
237
+ resolve({ code, stderr, text, streamed });
243
238
  });
244
239
 
245
- child.stdin.write(buildCliPrompt(messages, system));
240
+ child.stdin.write(prompt);
246
241
  child.stdin.end();
247
242
  });
248
243
  }
249
244
 
245
+ export async function chat({ messages, system, options, images }, emit, { signal } = {}) {
246
+ const tag = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
247
+ const outFile = path.join(os.tmpdir(), `chatpanel-codex-${tag}.txt`);
248
+ const imageFiles = await writeImages(images, tag);
249
+ const cleanupImages = () => imageFiles.forEach((f) => unlink(f).catch(() => {}));
250
+
251
+ const cwd = resolveWorkdir(options.workingDir);
252
+ // Sanitized once, not per attempt — pushExtraArgs emits a status when it drops something,
253
+ // and a retry must not say it twice.
254
+ const extraArgs = [];
255
+ pushExtraArgs(extraArgs, options.extraArgs, FORBIDDEN.codex, emit);
256
+
257
+ const buildArgs = (disabled) => {
258
+ const args = ['exec', '--json', '--skip-git-repo-check', '-o', outFile];
259
+ // Headless exec has no human to approve actions. With MCP/browser tools armed
260
+ // Codex would otherwise raise an approval prompt it can't show — and cancel the
261
+ // tool call. So in bypassPermissions (full autonomy, what "Act on page" needs)
262
+ // use the all-in bypass flag, which also clears MCP-tool approval. Lower modes
263
+ // keep the sandbox + never-ask, which auto-runs within bounds.
264
+ if (options.permissionMode === 'bypassPermissions') {
265
+ args.push('--dangerously-bypass-approvals-and-sandbox');
266
+ } else {
267
+ const sandbox = options.permissionMode === 'acceptEdits' ? 'workspace-write' : 'read-only';
268
+ args.push('-s', sandbox, '-c', 'approval_policy=never');
269
+ }
270
+ if (REASONING) args.push('-c', `model_reasoning_effort=${REASONING}`);
271
+ // Ask Codex to emit reasoning SUMMARIES so the panel can stream the model's thinking.
272
+ // Additive + safe: a no-op for models/providers that don't produce summaries (e.g. some
273
+ // hosted models expose none), and cheap at the default effort. forwardEvent renders them.
274
+ args.push('-c', 'model_reasoning_summary=auto');
275
+ // Browser tools: register the bridge's MCP server as a stdio MCP server (the
276
+ // bridge binary in --mcp-stdio mode), so Codex can call our page-action tools.
277
+ // `-c key=value` parses value as TOML; JSON.stringify yields valid TOML here.
278
+ args.push(...codexMcpConfigArgs(options.mcp));
279
+ // ...and the other direction: the user's own servers we've been told to leave out.
280
+ args.push(...codexDisableArgs(disabled));
281
+ if (options.model) args.push('-m', options.model);
282
+ // Drop caller extras that would re-open the sandbox/approval boundary (shared sanitizer).
283
+ args.push(...extraArgs);
284
+ for (const f of imageFiles) args.push('-i', f); // attach images to the initial prompt
285
+ args.push('-');
286
+ return args;
287
+ };
288
+
289
+ // Default: use the user's skills/config. Opt-out → isolated home.
290
+ const useLocal = options.useLocalConfig !== false;
291
+ const env = { ...process.env };
292
+ if (!useLocal) {
293
+ const home = ensureIsolatedHome();
294
+ if (home) env.CODEX_HOME = home;
295
+ }
296
+
297
+ const ownServer = options.mcp?.serverName || 'chatpanel_browser';
298
+ // Only servers Codex already knows about can be switched off — see configuredMcpServers.
299
+ const known = configuredMcpServers(env.CODEX_HOME || process.env.CODEX_HOME || path.join(os.homedir(), '.codex'));
300
+ let disabled = disabledMcpServers('codex', options, [ownServer]).filter((n) => known.has(n));
301
+ const prompt = buildCliPrompt(messages, system);
302
+ const attempt = () => runCodex({ args: buildArgs(disabled), cwd, env, prompt, outFile, emit, signal });
303
+
304
+ try {
305
+ let result = await attempt();
306
+
307
+ // The failure this exists for: one of the user's OWN MCP servers could not authenticate
308
+ // or could not be reached, and took a turn with it that never needed that server. Drop it
309
+ // and run again — once, out loud, and only while nothing has been streamed.
310
+ if (!signal?.aborted && result.code !== 0 && !result.streamed) {
311
+ const drop = planMcpRetry({ agent: 'codex', text: result.stderr, protect: [ownServer], already: disabled });
312
+ if (drop && known.has(drop.server)) {
313
+ quarantine('codex', drop.server);
314
+ emit({ type: 'status', text: `MCP server "${drop.server}" failed to load — ${drop.short}. Skipping it and retrying.` });
315
+ disabled = [...disabled, drop.server];
316
+ result = await attempt();
317
+ }
318
+ }
319
+
320
+ if (signal?.aborted) return; // Stop pressed — end quietly, no error
321
+ if (result.code !== 0) throw new Error(summarizeCliError('Codex', result.code, result.stderr));
322
+ emit({ type: 'delta', text: result.text || '(no output)' });
323
+ emit({ type: 'done', text: '' });
324
+ } finally {
325
+ cleanupImages();
326
+ }
327
+ }
328
+
250
329
  // Translate Codex `exec --json` events into the bridge's streaming vocabulary the panel
251
330
  // renders richly. Codex sends item.started (in_progress) then item.completed for each item,
252
331
  // reusing the item id — so we correlate a tool's start/done by that id and show its command,
@@ -621,7 +621,7 @@ export async function runSpec(spec, { messages, system, options = {}, images },
621
621
  emit({ type: 'done', text: parser.streamed ? '' : parser.finish() });
622
622
  resolve();
623
623
  } else {
624
- reject(new Error(`${label} exited ${code}: ${stderr.trim().split('\n').pop() || 'failed'}`));
624
+ reject(new Error(summarizeCliError(label, code, stderr)));
625
625
  }
626
626
  });
627
627
 
@@ -17,4 +17,4 @@
17
17
  // zero runtime dependencies by design; pulling the capability machinery and the event
18
18
  // schema behind it to reach a five-element array would be the transitive-graph mistake
19
19
  // the extension's first-paint budget exists to prevent, one repo over.
20
- export const DATA_SCOPES = Object.freeze(['notes', 'meetings', 'chats', 'page', 'files', 'net']);
20
+ export const DATA_SCOPES = Object.freeze(['notes', 'meetings', 'chats', 'memory', 'page', 'files', 'net']);
@@ -0,0 +1,110 @@
1
+ // Why a turn should not die because a server it never used could not log in.
2
+ //
3
+ // A CLI agent loads EVERY MCP server in the user's own config on every single run. One that
4
+ // cannot authenticate — an expired OAuth token, a VPN-only host seen from a coffee shop —
5
+ // takes the whole turn down with it, even when the question had nothing to do with that
6
+ // server. The user is then told to go re-login to something they never asked for.
7
+ //
8
+ // So: read the failure, name the server, drop it, run again. Two ways in —
9
+ // 1. the user's own deny list (`options.mcpDisabled`), for servers they know they don't
10
+ // want ChatPanel to load at all; and
11
+ // 2. QUARANTINE — a server that just killed a run is dropped automatically and stays
12
+ // dropped for the rest of the bridge's session, since it will not have healed in the
13
+ // twenty seconds before the next message.
14
+ //
15
+ // Two rules keep this honest. It is never silent: dropping a tool without saying so is worse
16
+ // than failing loudly, so the engine emits a status line naming what it skipped. And it never
17
+ // drops ChatPanel's OWN injected server — that one failing is our bug to surface, not a
18
+ // nuisance to route around (silently disabling it would take "Act on page" with it).
19
+ //
20
+ // Engine-agnostic on purpose: Codex renders this as `-c mcp_servers.X.enabled=false`, Claude
21
+ // Code and Copilot as their own flags, but the POLICY — which servers may load this turn —
22
+ // is one decision, made here, not re-derived per engine.
23
+
24
+ import { mcpFailure } from './cli-errors.js';
25
+
26
+ // A server that failed is dropped for this long. Long enough that a chat session never pays
27
+ // the same failed startup twice; short enough that reconnecting the VPN and waiting a while
28
+ // brings the server back without restarting the bridge.
29
+ const TTL_MS = Number(process.env.CHATPANEL_MCP_QUARANTINE_MS) || 30 * 60_000;
30
+
31
+ const dropped = new Map(); // `${agent} ${server}` -> expiry epoch ms
32
+
33
+ const key = (agent, server) => `${agent} ${server}`;
34
+
35
+ function prune(now = Date.now()) {
36
+ for (const [k, expiry] of dropped) if (expiry <= now) dropped.delete(k);
37
+ }
38
+
39
+ /**
40
+ * Server names are interpolated into a config override key (`mcp_servers.<name>.enabled`),
41
+ * so they are validated rather than escaped: anything that isn't a plain MCP server name is
42
+ * dropped. Dots are excluded too — Codex's `-c` parser reads them as further path segments
43
+ * and rejects the quoted form. Accepts an array or a comma/space-separated string (what the
44
+ * settings field holds).
45
+ */
46
+ const NAME_RE = /^[A-Za-z0-9_-]{1,64}$/;
47
+
48
+ /** One name, validated whole — no splitting, so junk is rejected rather than chopped valid. */
49
+ export function validName(value) {
50
+ const name = String(value || '').trim();
51
+ return NAME_RE.test(name) ? name : null;
52
+ }
53
+
54
+ export function normalizeNames(value) {
55
+ const list = Array.isArray(value) ? value : String(value || '').split(/[,\s]+/);
56
+ const out = [];
57
+ for (const raw of list) {
58
+ const name = validName(raw);
59
+ if (name && !out.includes(name)) out.push(name);
60
+ }
61
+ return out;
62
+ }
63
+
64
+ /** The servers currently quarantined for an agent. */
65
+ export function quarantined(agent) {
66
+ prune();
67
+ const prefix = key(agent, '');
68
+ return [...dropped.keys()].filter((k) => k.startsWith(prefix)).map((k) => k.slice(prefix.length));
69
+ }
70
+
71
+ /** Drop a server for this agent. Returns false when it was already dropped. */
72
+ export function quarantine(agent, server) {
73
+ const name = validName(server);
74
+ if (!name) return false;
75
+ const k = key(agent, name);
76
+ const fresh = !dropped.has(k);
77
+ dropped.set(k, Date.now() + TTL_MS);
78
+ return fresh;
79
+ }
80
+
81
+ /** Test seam — the store is process-global by design. */
82
+ export function resetQuarantine() {
83
+ dropped.clear();
84
+ }
85
+
86
+ /**
87
+ * Every server this run must not load: the user's deny list plus anything quarantined,
88
+ * minus the servers ChatPanel itself injected (never route around our own).
89
+ */
90
+ export function disabledMcpServers(agent, options = {}, protect = []) {
91
+ const guard = new Set(normalizeNames(protect));
92
+ const names = [...normalizeNames(options.mcpDisabled), ...quarantined(agent)];
93
+ return [...new Set(names)].filter((n) => !guard.has(n));
94
+ }
95
+
96
+ /**
97
+ * Should this failed run be retried without one of the agent's own MCP servers?
98
+ * Returns the server to drop, or null — and null is the safe answer: a failure that names no
99
+ * server, or names one we already dropped, means retrying would only fail the same way.
100
+ * @returns {{server: string, kind: string, short: string}|null}
101
+ */
102
+ export function planMcpRetry({ agent = '', text = '', protect = [], already = [] } = {}) {
103
+ const failure = mcpFailure(text);
104
+ if (!failure?.server) return null;
105
+ if (!validName(failure.server)) return null; // a name we could never write as an override
106
+ if (normalizeNames(protect).includes(failure.server)) return null;
107
+ if (normalizeNames(already).includes(failure.server)) return null;
108
+ if (quarantined(agent).includes(failure.server)) return null;
109
+ return { server: failure.server, kind: failure.kind, short: failure.short };
110
+ }
package/src/server.js CHANGED
@@ -67,7 +67,7 @@ import {
67
67
  // Hardcoded (not read from package.json) so it survives Bun's single-file
68
68
  // --compile, where package.json isn't on a readable FS. CI fails the publish if
69
69
  // this drifts from package.json, so the two can't silently diverge.
70
- const VERSION = '0.10.40';
70
+ const VERSION = '0.10.42';
71
71
  const HOST = process.env.CHATPANEL_BRIDGE_HOST || '127.0.0.1';
72
72
  const PORT = Number(process.env.CHATPANEL_BRIDGE_PORT) || 4319;
73
73