@chatpanel/bridge 0.10.21 → 0.10.23

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,8 +1,8 @@
1
1
  {
2
2
  "name": "@chatpanel/bridge",
3
- "version": "0.10.21",
3
+ "version": "0.10.23",
4
4
  "type": "module",
5
- "description": "Local bridge that exposes the AI coding agents installed on your machine Claude Code (CLI), Codex (CLI), and Antigravity CLI (formerly Gemini CLI, which remains available for business/enterprise) to the ChatPanel Chrome extension over a localhost SSE endpoint. Bring your own agent.",
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": [
7
7
  "chatpanel",
8
8
  "claude-code",
@@ -16,6 +16,8 @@ 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 { killOnAbort } from '../proc.js';
20
+ import { pushExtraArgs, FORBIDDEN } from './args.js';
19
21
 
20
22
  const IDLE_MS = Number(process.env.CHATPANEL_AGY_TIMEOUT_MS) || 180_000;
21
23
  const SCRATCH = path.join(os.tmpdir(), 'chatpanel-agy-scratch');
@@ -78,7 +80,7 @@ function writeImages(images, dir) {
78
80
  return files;
79
81
  }
80
82
 
81
- export async function chat({ messages, system, options, images }, emit) {
83
+ export async function chat({ messages, system, options, images }, emit, { signal } = {}) {
82
84
  try {
83
85
  mkdirSync(SCRATCH, { recursive: true });
84
86
  } catch {
@@ -102,7 +104,8 @@ export async function chat({ messages, system, options, images }, emit) {
102
104
  const args = ['-p', prompt];
103
105
  if (options.model) args.push('--model', options.model);
104
106
  if (options.permissionMode === 'bypassPermissions') args.push('--dangerously-skip-permissions');
105
- if (options.extraArgs) args.push(...String(options.extraArgs).split(/\s+/).filter(Boolean));
107
+ // Drop caller extras that would auto-approve tools (shared sanitizer).
108
+ pushExtraArgs(args, options.extraArgs, FORBIDDEN.antigravity, emit);
106
109
 
107
110
  await new Promise((resolve, reject) => {
108
111
  let child;
@@ -113,6 +116,8 @@ export async function chat({ messages, system, options, images }, emit) {
113
116
  return reject(new Error(`Failed to start agy: ${e.message}`));
114
117
  }
115
118
 
119
+ const detach = killOnAbort(child, signal); // Stop → terminate the agy child
120
+
116
121
  let out = '';
117
122
  let err = '';
118
123
  let streamed = false;
@@ -137,12 +142,15 @@ export async function chat({ messages, system, options, images }, emit) {
137
142
  child.stderr.on('data', (d) => { armIdle(); err += d.toString(); });
138
143
  child.on('error', (e) => {
139
144
  clearTimeout(idleTimer);
145
+ detach();
140
146
  cleanup();
141
147
  reject(new Error(`Failed to start agy: ${e.message}`));
142
148
  });
143
149
  child.on('close', (code) => {
144
150
  clearTimeout(idleTimer);
151
+ detach();
145
152
  cleanup();
153
+ if (signal?.aborted) { resolve(); return; } // Stop pressed — end quietly
146
154
  if (code === 0) {
147
155
  if (!streamed) emit({ type: 'delta', text: out.trim() || '(no output)' });
148
156
  emit({ type: 'done', text: '' });
@@ -0,0 +1,47 @@
1
+ // Caller-supplied "extra CLI arguments" sanitizer (shared by every engine).
2
+ //
3
+ // The extension/model can pass free-form extraArgs to the agent CLI. A prompt-
4
+ // injected model must NOT be able to smuggle a flag that re-opens the sandbox /
5
+ // permission boundary the engine deliberately sets (e.g. Codex's
6
+ // --dangerously-bypass-approvals-and-sandbox, Claude's --permission-mode). Since
7
+ // these flags take values, partial filtering is unsafe — if ANY forbidden token is
8
+ // present we drop the WHOLE extraArgs. Previously only claude.js did this; codex,
9
+ // antigravity and custom pushed extraArgs unfiltered (this closes that gap).
10
+
11
+ export function splitArgs(raw) {
12
+ return String(raw || '').split(/\s+/).filter(Boolean);
13
+ }
14
+
15
+ // Per-engine escalation flags. Long flags use \b so `--flag=value` is caught too;
16
+ // short flags are exact-matched so we don't over-block a benign token.
17
+ export const FORBIDDEN = {
18
+ claude: /^--?(permission-mode|allowed-?tools|disallowed-?tools|dangerously|add-dir|mcp-config|setting-sources|permission-prompt-tool)/i,
19
+ // Codex: sandbox / approval escalation + `-c key=val` (can set approval_policy or
20
+ // sandbox_mode in TOML) + `-C/--cd` (retarget the working dir).
21
+ codex: /^(-s|-a|-c|-C)$|^--(dangerously[\w-]*|sandbox|ask-for-approval|full-auto|yolo|config|cd)\b/i,
22
+ antigravity: /^--(dangerously[\w-]*|skip-permissions|trust-all-?tools|yolo|full-auto)\b/i,
23
+ // Custom runs an arbitrary CLI, so only clearly-dangerous LONG flags are blocked
24
+ // (no short-flag guesses that might collide with a benign tool option).
25
+ custom: /^--(dangerously[\w-]*|skip-permissions|trust-all-?tools|no-sandbox|bypass|yolo|full-auto|permission-mode|allowed-?tools|disallowed-?tools|mcp-config)\b/i,
26
+ // Copilot's escalation surface is its own family of --allow-* flags (which the
27
+ // `custom` pattern above does NOT cover: "allow-all-tools" != "allowed-tools").
28
+ // Also block re-targeting the working dir (-C / --add-dir) and injecting MCP
29
+ // servers, since the engine sets those deliberately per turn.
30
+ copilot: /^(-C)$|^--(allow-all[\w-]*|allow-tool|allow-url|allow-path|yolo|add-dir|additional-mcp-config|disable-builtin-mcps|deny-tool|deny-url|autopilot|mode)\b/i,
31
+ };
32
+
33
+ // Returns { args, blocked }. `blocked:true` => the whole extraArgs was dropped.
34
+ export function sanitizeExtraArgs(raw, forbidden) {
35
+ const tokens = splitArgs(raw);
36
+ if (tokens.some((t) => forbidden.test(t))) return { args: [], blocked: true };
37
+ return { args: tokens, blocked: false };
38
+ }
39
+
40
+ // Convenience for the engines: sanitize, push the safe tokens onto `args`, and emit
41
+ // a status when something was dropped.
42
+ export function pushExtraArgs(args, raw, forbidden, emit) {
43
+ if (!raw) return;
44
+ const { args: extra, blocked } = sanitizeExtraArgs(raw, forbidden);
45
+ if (blocked) { try { emit?.({ type: 'status', text: '(ignored unsafe extraArgs)' }); } catch { /* ignore */ } return; }
46
+ args.push(...extra);
47
+ }
@@ -19,6 +19,8 @@ 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 { killOnAbort } from '../proc.js';
23
+ import { pushExtraArgs, FORBIDDEN } from './args.js';
22
24
 
23
25
  // Write base64 data-URL images to temp files. Claude Code reads them with its
24
26
  // Read tool (which feeds images to the model as vision), so we just reference the
@@ -95,7 +97,7 @@ export function claudeMcpConfig(mcp) {
95
97
  // Spawn claude (however it resolves) and stream its stream-json output via
96
98
  // `emit`. Resolves with { streamedAny, resultText } once it closes 0. Returns
97
99
  // null (no spawn) when claude can't be resolved, so the caller can fall back.
98
- function runClaude({ prompt, args, cwd, emit }) {
100
+ function runClaude({ prompt, args, cwd, emit, signal }) {
99
101
  const spec = resolveClaude();
100
102
  if (!spec) return null;
101
103
  const [bin, argv, opts] = buildSpawnSpec(spec, args, cwd);
@@ -108,6 +110,8 @@ function runClaude({ prompt, args, cwd, emit }) {
108
110
  return reject(new Error(`Failed to start claude: ${e.message}`));
109
111
  }
110
112
 
113
+ const detach = killOnAbort(child, signal); // Stop → terminate the claude child
114
+
111
115
  let stdout = '';
112
116
  let stderr = '';
113
117
  let streamedAny = false;
@@ -145,10 +149,13 @@ function runClaude({ prompt, args, cwd, emit }) {
145
149
  child.stderr.on('data', (d) => { armIdle(); stderr += d.toString(); });
146
150
  child.on('error', (e) => {
147
151
  clearTimeout(idleTimer);
152
+ detach();
148
153
  reject(new Error(`Failed to start claude (${bin}): ${e.message}`));
149
154
  });
150
155
  child.on('close', (code) => {
151
156
  clearTimeout(idleTimer);
157
+ detach();
158
+ if (signal?.aborted) { resolve({ streamedAny, resultText }); return; } // Stop pressed — end quietly
152
159
  if (code === 0) resolve({ streamedAny, resultText });
153
160
  else reject(new Error(`Claude Code exited ${code}: ${stderr.trim().split('\n').pop() || 'failed'}`));
154
161
  });
@@ -204,7 +211,7 @@ export function handleMessage(msg, emit, alreadyStreamed) {
204
211
  return out;
205
212
  }
206
213
 
207
- export async function chat({ messages, system, options, images }, emit) {
214
+ export async function chat({ messages, system, options, images }, emit, { signal } = {}) {
208
215
  const permissionMode = options.permissionMode || 'default';
209
216
  // Explicit project dir, else null → CLI runs in home (or WSL home).
210
217
  const cwd = options.workingDir ? path.resolve(options.workingDir) : null;
@@ -259,22 +266,13 @@ export async function chat({ messages, system, options, images }, emit) {
259
266
  }: ${imageFiles.join(', ')}`;
260
267
  }
261
268
 
262
- if (options.extraArgs) {
263
- const extra = String(options.extraArgs).split(/\s+/).filter(Boolean);
264
- // Never let caller-supplied extras re-open the read-only boundary the mode
265
- // flags above establish. If ANY security-sensitive flag is present, drop the
266
- // whole extraArgs (these tokens take values, so partial filtering is unsafe).
267
- const FORBIDDEN = /^--?(permission-mode|allowed-?tools|disallowed-?tools|dangerously|add-dir|mcp-config|setting-sources|permission-prompt-tool)/i;
268
- if (extra.some((t) => FORBIDDEN.test(t))) {
269
- emit({ type: 'status', text: '(ignored unsafe extraArgs)' });
270
- } else {
271
- args.push(...extra);
272
- }
273
- }
274
- const run = runClaude({ prompt, args, cwd, emit });
269
+ // Never let caller-supplied extras re-open the read-only boundary the mode flags
270
+ // above establish (shared sanitizer — see args.js).
271
+ pushExtraArgs(args, options.extraArgs, FORBIDDEN.claude, emit);
272
+ const run = runClaude({ prompt, args, cwd, emit, signal });
275
273
  if (run === null) {
276
274
  cleanup(); // SDK fallback doesn't take images yet
277
- return sdkChat({ messages, system, options }, emit);
275
+ return sdkChat({ messages, system, options }, emit, { signal });
278
276
  }
279
277
  try {
280
278
  const { streamedAny, resultText } = await run;
@@ -335,11 +333,18 @@ function loadSdk() {
335
333
  return sdkPromise;
336
334
  }
337
335
 
338
- async function sdkChat({ messages, system, options }, emit) {
336
+ async function sdkChat({ messages, system, options }, emit, { signal } = {}) {
339
337
  const sdk = await loadSdk();
340
338
  if (!sdk) throw new Error(lastReason);
341
339
  const { query } = sdk;
342
340
 
341
+ // The SDK cancels the run when this controller aborts — forward the request signal.
342
+ const abortController = new AbortController();
343
+ if (signal) {
344
+ if (signal.aborted) abortController.abort();
345
+ else signal.addEventListener('abort', () => abortController.abort(), { once: true });
346
+ }
347
+
343
348
  const permissionMode = options.permissionMode || 'default';
344
349
  const cwd = options.workingDir ? path.resolve(options.workingDir) : os.homedir();
345
350
  const writesAllowed = permissionMode === 'acceptEdits' || permissionMode === 'bypassPermissions';
@@ -358,6 +363,7 @@ async function sdkChat({ messages, system, options }, emit) {
358
363
  permissionMode,
359
364
  includePartialMessages: true,
360
365
  canUseTool,
366
+ abortController,
361
367
  settingSources: options.useLocalConfig === false ? [] : ['user', 'project'],
362
368
  systemPrompt: system
363
369
  ? { type: 'preset', preset: 'claude_code', append: system }
@@ -366,10 +372,15 @@ async function sdkChat({ messages, system, options }, emit) {
366
372
  ...(process.env.CHATPANEL_MAX_TURNS ? { maxTurns: Number(process.env.CHATPANEL_MAX_TURNS) } : {}),
367
373
  },
368
374
  });
369
- for await (const message of iterator) {
370
- const r = handleMessage(message, emit, streamedAny);
371
- if (r.streamed) streamedAny = true;
372
- if (r.result != null) resultText = r.result;
375
+ try {
376
+ for await (const message of iterator) {
377
+ const r = handleMessage(message, emit, streamedAny);
378
+ if (r.streamed) streamedAny = true;
379
+ if (r.result != null) resultText = r.result;
380
+ }
381
+ } catch (e) {
382
+ if (signal?.aborted || abortController.signal.aborted) return; // Stop pressed — end quietly
383
+ throw e;
373
384
  }
374
385
  emit({ type: 'done', text: streamedAny ? '' : resultText });
375
386
  }
@@ -1,18 +1,19 @@
1
1
  // Built-in CLI agents — pi, opencode, kiro — that reuse the shared custom-engine
2
- // runner (runSpec) with a FIXED spec each. Unlike the Pro "custom" engine, these
3
- // are NOT entitlement-gated: they ship as built-ins, and the extension bounds
4
- // free users to a single usable agent (FREE_LIMITS.bridgeAgents). Bring-your-own
5
- // arbitrary CLIs stay Pro via custom.js.
2
+ // runner (runSpec) with a fixed spec each. These are fixed built-in engines and are
3
+ // not entitlement-gated; custom BYO CLIs are handled by custom.js.
6
4
  //
7
5
  // Specs come from each CLI's actual flags:
8
6
  // pi — pi -p "<prompt>" · --model · @{path} images · --list-models
9
7
  // opencode — opencode run "<prompt>" · -m provider/model · -f {path} images · models
10
8
  // kiro — kiro-cli chat --no-interactive "<prompt>" · --model · --list-models
11
9
 
12
- import { runSpec, listSpecModels } from './custom.js';
10
+ import { runSpec, listSpecModels, runForStdout } from './custom.js';
13
11
  import { findAgentBin } from '../env.js';
14
12
 
15
- function makeCliAgent(command, spec, notFoundHint) {
13
+ // `listModels` override hook: a CLI whose model listing isn't "one id per line"
14
+ // (Copilot prints a quoted list inside `help config`) passes its own parser here
15
+ // instead of forcing the generic one to grow special cases.
16
+ function makeCliAgent(command, spec, notFoundHint, overrides = {}) {
16
17
  let installed = false;
17
18
  let lastProbe = 0;
18
19
  const resolvedSpec = { ...spec, command };
@@ -32,10 +33,11 @@ function makeCliAgent(command, spec, notFoundHint) {
32
33
  return installed ? { ok: true } : { ok: false, reason: notFoundHint };
33
34
  },
34
35
  listModels(options = {}) {
36
+ if (overrides.listModels) return overrides.listModels(command, options);
35
37
  return listSpecModels(command, spec.listModelsArgs, options.workingDir);
36
38
  },
37
- chat(input, emit) {
38
- return runSpec(resolvedSpec, input, emit);
39
+ chat(input, emit, opts) {
40
+ return runSpec(resolvedSpec, input, emit, opts);
39
41
  },
40
42
  };
41
43
  }
@@ -96,3 +98,153 @@ export const kiro = makeCliAgent(
96
98
  },
97
99
  'kiro-cli not found on PATH. Install Kiro CLI, then sign in.',
98
100
  );
101
+
102
+ // GitHub Copilot CLI. Verified against 1.0.80 (`copilot`, not the old
103
+ // `gh copilot` extension).
104
+ //
105
+ // The two things that make or break headless Copilot:
106
+ // 1. --allow-all-tools is REQUIRED in -p mode. Without it EVERY tool call dies
107
+ // with "Permission denied and could not request permission from user" — the
108
+ // CLI has no TTY to ask on — so the agent answers as if it had no tools.
109
+ // 2. --output-format json turns the TUI into clean NDJSON we can stream;
110
+ // the default text output is not parseable as a live stream.
111
+ // Both live in the base spec because neither is optional for this integration.
112
+ //
113
+ // Permission mode maps onto Copilot's real permission surface (see
114
+ // permissionArgs): tools always run unattended, while filesystem reach beyond the
115
+ // working dir and arbitrary URL access are what the mode actually escalates.
116
+ const COPILOT_BASE_ARGS = [
117
+ '-p', '{prompt}',
118
+ '--output-format', 'json',
119
+ '--no-color',
120
+ '--log-level', 'none',
121
+ // Headless hygiene: never block asking the user a question, never self-update
122
+ // mid-turn, and don't ship the session to GitHub web/mobile from a ChatPanel
123
+ // turn (Copilot exports by default; ChatPanel keeps chat local by policy).
124
+ '--no-ask-user',
125
+ '--no-auto-update',
126
+ '--no-remote',
127
+ '--no-remote-export',
128
+ ];
129
+
130
+ export const copilot = makeCliAgent(
131
+ 'copilot',
132
+ {
133
+ args: COPILOT_BASE_ARGS,
134
+ promptVia: 'arg',
135
+ modelArg: '--model {model}',
136
+ // Non-interactive-only flag; one per image.
137
+ imageArg: '--attachment {path}',
138
+ format: 'copilot-json',
139
+ // Copilot takes per-run MCP servers, so browser tools work WITHOUT touching
140
+ // the user's ~/.copilot/mcp-config.json (unlike opencode/kiro, which only
141
+ // read global config). `@` prefix = "this is a file path, not inline JSON";
142
+ // the shape runSpec writes ({mcpServers:{name:{command,args}}}) is the one
143
+ // Copilot expects — verified.
144
+ mcpArg: '--additional-mcp-config @{file}',
145
+ permissionArgs: {
146
+ // Tools yes (or nothing works); no path escape, no arbitrary URLs.
147
+ default: ['--allow-all-tools'],
148
+ // Edits anywhere on disk, still no arbitrary URL fetching.
149
+ acceptEdits: ['--allow-all-tools', '--allow-all-paths'],
150
+ // == --allow-all-tools --allow-all-paths --allow-all-urls
151
+ bypassPermissions: ['--allow-all'],
152
+ },
153
+ forbidden: 'copilot',
154
+ label: 'GitHub Copilot',
155
+ },
156
+ 'copilot not found on PATH. Install GitHub Copilot CLI, then run `copilot login`.',
157
+ { listModels: (command, options) => listCopilotModels(command, options.workingDir) },
158
+ );
159
+
160
+ // Copilot has no `--list-models`, and an invalid --model reports only that the
161
+ // model is unavailable without naming the valid ones. `copilot help config`
162
+ // documents the live list under its `model` key, so read it there — the ids stay
163
+ // in step with the installed CLI instead of being hardcoded here.
164
+ export async function listCopilotModels(command = 'copilot', workingDir) {
165
+ const stdout = await runForStdout(command, ['help', 'config'], workingDir);
166
+ const out = [];
167
+ const seen = new Set();
168
+ let inModelBlock = false;
169
+ for (const raw of String(stdout || '').split('\n')) {
170
+ // Section keys are printed as ` `key`: description`.
171
+ const key = /^\s*`([A-Za-z][\w.]*)`\s*:/.exec(raw);
172
+ if (key) {
173
+ inModelBlock = key[1] === 'model';
174
+ continue;
175
+ }
176
+ if (!inModelBlock) continue;
177
+ const m = /^\s*-\s*"([^"]+)"\s*$/.exec(raw);
178
+ if (!m) continue;
179
+ const id = m[1];
180
+ if (seen.has(id)) continue;
181
+ seen.add(id);
182
+ out.push(id);
183
+ if (out.length >= 200) break;
184
+ }
185
+ // `auto` is a real, documented value (Copilot routes the turn itself) but it is
186
+ // not in the enumerated list — offer it first.
187
+ return out.length ? ['auto', ...out] : [];
188
+ }
189
+
190
+ // DeepSeek Harness. The launcher is `dsh`; `--profile headless` is the shipped
191
+ // one-shot template ("answer one task, print the final assistant message, exit").
192
+ //
193
+ // Deliberately sparse compared to the others, because the CLI surface is sparse:
194
+ // the headless profile takes ONLY the task text. There is no --model, no JSON
195
+ // output and no resume flag — dsh is a Cordis plugin tree, so the model adapter,
196
+ // tools and MCP are chosen by the PROFILE, not by argv. Power users retarget it
197
+ // with `--patch <file>` through the agent's "Extra arguments" field.
198
+ //
199
+ // Two consequences worth knowing before wiring UI to this:
200
+ // * `modelArg` is absent, so a model picked in Settings is ignored by design
201
+ // (runSpec skips model injection without a template) — configure it in the
202
+ // profile instead.
203
+ // * Output arrives as ONE chunk at the end, not token-by-token, so the turn
204
+ // shows progress but no live typing.
205
+ export const deepseek = makeCliAgent(
206
+ 'dsh',
207
+ {
208
+ args: ['--profile', 'headless', '{prompt}'],
209
+ promptVia: 'arg',
210
+ format: 'text',
211
+ // No --model flag exists. dsh takes the model as a CONFIG OVERLAY: --patch
212
+ // replaces one row of the composed plugin tree by id, and the model lives in
213
+ // the `agent-default-model` row. runSpec writes this file per turn.
214
+ modelPatch: {
215
+ arg: '--patch {file}',
216
+ // A patch replaces the row's WHOLE config, so provider must be restated.
217
+ build: (model) => `- id: ${DSH_MODEL_ROW}\n config:\n provider: ${DSH_PROVIDER}\n model: ${JSON.stringify(model)}\n`,
218
+ },
219
+ label: 'DeepSeek Harness',
220
+ },
221
+ 'dsh not found on PATH. Install DeepSeek Harness, then run `dsh --profile headless "hi"` once to check the profile.',
222
+ { listModels: (command, options) => listDshModels(command, options.workingDir) },
223
+ );
224
+
225
+ const DSH_MODEL_ROW = 'agent-default-model';
226
+ const DSH_PROVIDER = 'deepseek-official';
227
+ // dsh ships no model catalog command. The provider names its supported ids only
228
+ // when you send a bad one ("The supported API model names are ..."), so these are
229
+ // those ids, unioned with whatever the profile is actually set to. The field
230
+ // still accepts any id — this is a convenience menu, not a whitelist.
231
+ const DSH_KNOWN_MODELS = ['deepseek-v4-flash', 'deepseek-v4-pro'];
232
+
233
+ // dsh publishes no model catalog — there is no `--list-models`, and the composed
234
+ // config tree carries only the model the profile is CURRENTLY set to. So "Load
235
+ // models" reports that one truthfully rather than inventing a menu; the field
236
+ // still accepts any id the provider serves, applied via the patch above.
237
+ export async function listDshModels(command = 'dsh', workingDir) {
238
+ const stdout = await runForStdout(command, ['--profile', 'headless', '--dump-config'], workingDir, 60000);
239
+ const lines = String(stdout || '').split('\n');
240
+ const rowStart = new RegExp(`^-\\s*id:\\s*${DSH_MODEL_ROW}\\s*$`);
241
+ for (let i = 0; i < lines.length; i++) {
242
+ if (!rowStart.test(lines[i])) continue;
243
+ // Scan this row's block for `model: <id>`, stopping at the next top-level row.
244
+ for (let j = i + 1; j < lines.length && !/^-\s*id:/.test(lines[j]); j++) {
245
+ const m = /^\s*model:\s*['"]?([\w.:-]+)['"]?\s*$/.exec(lines[j]);
246
+ if (m) return [...new Set([m[1], ...DSH_KNOWN_MODELS])];
247
+ }
248
+ }
249
+ return [...DSH_KNOWN_MODELS];
250
+ }
@@ -15,12 +15,14 @@
15
15
  // the agent to point it at a real project.
16
16
 
17
17
  import { spawn, spawnSync } from 'node:child_process';
18
+ import { killOnAbort } from '../proc.js';
18
19
  import { readFile, unlink, writeFile } from 'node:fs/promises';
19
20
  import { existsSync, mkdirSync, symlinkSync, readFileSync } from 'node:fs';
20
21
  import os from 'node:os';
21
22
  import path from 'node:path';
22
23
  import { findAgentBin, selfMcpStdio } from '../env.js';
23
24
  import { buildCliPrompt } from './prompt.js';
25
+ import { pushExtraArgs, FORBIDDEN } from './args.js';
24
26
 
25
27
  // Idle timeout: re-armed on every stdout/stderr chunk, so a long run that keeps
26
28
  // streaming never trips it — only true silence does. Override with
@@ -136,7 +138,7 @@ async function writeImages(images, tag) {
136
138
  return files;
137
139
  }
138
140
 
139
- export async function chat({ messages, system, options, images }, emit) {
141
+ export async function chat({ messages, system, options, images }, emit, { signal } = {}) {
140
142
  ensureScratch();
141
143
  const tag = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
142
144
  const outFile = path.join(os.tmpdir(), `chatpanel-codex-${tag}.txt`);
@@ -162,7 +164,8 @@ export async function chat({ messages, system, options, images }, emit) {
162
164
  // `-c key=value` parses value as TOML; JSON.stringify yields valid TOML here.
163
165
  args.push(...codexMcpConfigArgs(options.mcp));
164
166
  if (options.model) args.push('-m', options.model);
165
- if (options.extraArgs) args.push(...String(options.extraArgs).split(/\s+/).filter(Boolean));
167
+ // Drop caller extras that would re-open the sandbox/approval boundary (shared sanitizer).
168
+ pushExtraArgs(args, options.extraArgs, FORBIDDEN.codex, emit);
166
169
  for (const f of imageFiles) args.push('-i', f); // attach images to the initial prompt
167
170
  args.push('-');
168
171
 
@@ -183,6 +186,8 @@ export async function chat({ messages, system, options, images }, emit) {
183
186
  return reject(new Error(`Failed to start codex: ${e.message}`));
184
187
  }
185
188
 
189
+ const detach = killOnAbort(child, signal); // Stop → SIGTERM/SIGKILL the codex child
190
+
186
191
  let stdout = '';
187
192
  let stderr = '';
188
193
  let idleTimer;
@@ -213,11 +218,13 @@ export async function chat({ messages, system, options, images }, emit) {
213
218
  child.stderr.on('data', (d) => { armIdle(); stderr += d.toString(); });
214
219
  child.on('error', (e) => {
215
220
  clearTimeout(idleTimer);
221
+ detach();
216
222
  cleanupImages();
217
223
  reject(e);
218
224
  });
219
225
  child.on('close', async (code) => {
220
226
  clearTimeout(idleTimer);
227
+ detach();
221
228
  let text = '';
222
229
  try {
223
230
  text = (await readFile(outFile, 'utf8')).trim();
@@ -226,6 +233,7 @@ export async function chat({ messages, system, options, images }, emit) {
226
233
  }
227
234
  unlink(outFile).catch(() => {});
228
235
  cleanupImages();
236
+ if (signal?.aborted) { resolve(); return; } // Stop pressed — end quietly, no error
229
237
  if (code === 0) {
230
238
  emit({ type: 'delta', text: text || '(no output)' });
231
239
  emit({ type: 'done', text: '' });