@chatpanel/bridge 0.10.29 → 0.10.30

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.29",
3
+ "version": "0.10.30",
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": [
@@ -18,9 +18,9 @@ import { findAgentBin } from '../env.js';
18
18
  import { buildCliPrompt } from './prompt.js';
19
19
  import { killOnAbort, spawnGroupOpts } from '../proc.js';
20
20
  import { pushExtraArgs, FORBIDDEN } from './args.js';
21
+ import { resolveWorkdir } from '../workdir.js';
21
22
 
22
23
  const IDLE_MS = Number(process.env.CHATPANEL_AGY_TIMEOUT_MS) || 180_000;
23
- const SCRATCH = path.join(os.tmpdir(), 'chatpanel-agy-scratch');
24
24
 
25
25
  // `agy models` lists available models. Parse ids best-effort; free text is still
26
26
  // accepted by the picker, and [] just means "type a model or use the default".
@@ -81,12 +81,7 @@ function writeImages(images, dir) {
81
81
  }
82
82
 
83
83
  export async function chat({ messages, system, options, images }, emit, { signal } = {}) {
84
- try {
85
- mkdirSync(SCRATCH, { recursive: true });
86
- } catch {
87
- /* best effort */
88
- }
89
- const cwd = options.workingDir ? path.resolve(options.workingDir) : SCRATCH;
84
+ const cwd = resolveWorkdir(options.workingDir);
90
85
 
91
86
  // Images: write into the cwd (workspace) and reference with `@<file>` — agy
92
87
  // reads @-referenced files (incl. images) inline as multimodal input, so no
@@ -21,6 +21,7 @@ import { resolveClaude, buildSpawnSpec, isCompiledBinary, selfMcpStdio } from '.
21
21
  import { buildCliPrompt } from './prompt.js';
22
22
  import { killOnAbort } from '../proc.js';
23
23
  import { pushExtraArgs, FORBIDDEN } from './args.js';
24
+ import { displayPath, resolveWorkdir } from '../workdir.js';
24
25
 
25
26
  // Write base64 data-URL images to temp files. Claude Code reads them with its
26
27
  // Read tool (which feeds images to the model as vision), so we just reference the
@@ -141,7 +142,7 @@ function runClaude({ prompt, args, cwd, emit, signal }) {
141
142
  } catch {
142
143
  continue; // not a JSON event line
143
144
  }
144
- const r = handleMessage(msg, emit, streamedAny);
145
+ const r = handleMessage(msg, emit, streamedAny, cwd);
145
146
  if (r.streamed) streamedAny = true;
146
147
  if (r.result != null) resultText = r.result;
147
148
  }
@@ -168,7 +169,7 @@ function runClaude({ prompt, args, cwd, emit, signal }) {
168
169
  // Map one stream-json message to emit() calls. Returns { streamed, result }.
169
170
  // The CLI's stream-json mirrors the SDK message shapes. Exported so the custom
170
171
  // engine can reuse it for agents that emit Claude-style stream-json.
171
- export function handleMessage(msg, emit, alreadyStreamed) {
172
+ export function handleMessage(msg, emit, alreadyStreamed, cwdForSteps = '') {
172
173
  const out = { streamed: false, result: null };
173
174
  if (msg.type === 'stream_event') {
174
175
  const ev = msg.event;
@@ -183,7 +184,7 @@ export function handleMessage(msg, emit, alreadyStreamed) {
183
184
  } else if (msg.type === 'assistant') {
184
185
  for (const block of msg.message?.content || []) {
185
186
  if (block.type === 'tool_use') {
186
- emit({ type: 'tool', name: block.name, summary: toolSummary(block) });
187
+ emit({ type: 'tool', name: block.name, summary: toolSummary(block, cwdForSteps) });
187
188
  } else if (block.type === 'text' && !alreadyStreamed) {
188
189
  out.streamed = true;
189
190
  emit({ type: 'delta', text: block.text });
@@ -214,7 +215,7 @@ export function handleMessage(msg, emit, alreadyStreamed) {
214
215
  export async function chat({ messages, system, options, images }, emit, { signal } = {}) {
215
216
  const permissionMode = options.permissionMode || 'default';
216
217
  // Explicit project dir, else null → CLI runs in home (or WSL home).
217
- const cwd = options.workingDir ? path.resolve(options.workingDir) : null;
218
+ const cwd = resolveWorkdir(options.workingDir);
218
219
 
219
220
  const tag = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
220
221
  const args = ['--print', '--output-format', 'stream-json', '--include-partial-messages', '--verbose'];
@@ -308,10 +309,12 @@ export async function complete({ prompt, system, model }) {
308
309
  return (text || resultText || '').trim();
309
310
  }
310
311
 
311
- function toolSummary(block) {
312
+ function toolSummary(block, cwd = '') {
312
313
  const i = block.input || {};
313
314
  if (i.command) return String(i.command).slice(0, 60);
314
- if (i.file_path) return path.basename(i.file_path);
315
+ // Relative to the working directory rather than a bare basename: "src/foo.js" says
316
+ // where the file is, "foo.js" left the user guessing which of four roots it meant.
317
+ if (i.file_path) return displayPath(i.file_path, cwd);
315
318
  if (i.pattern) return i.pattern;
316
319
  if (i.url) return i.url;
317
320
  if (i.description) return String(i.description).slice(0, 80); // Task (subagent) — what it's for
@@ -346,7 +349,7 @@ async function sdkChat({ messages, system, options }, emit, { signal } = {}) {
346
349
  }
347
350
 
348
351
  const permissionMode = options.permissionMode || 'default';
349
- const cwd = options.workingDir ? path.resolve(options.workingDir) : os.homedir();
352
+ const cwd = resolveWorkdir(options.workingDir);
350
353
  const writesAllowed = permissionMode === 'acceptEdits' || permissionMode === 'bypassPermissions';
351
354
  const readonly = new Set(READONLY_TOOLS);
352
355
  const canUseTool = async (toolName) =>
@@ -374,7 +377,7 @@ async function sdkChat({ messages, system, options }, emit, { signal } = {}) {
374
377
  });
375
378
  try {
376
379
  for await (const message of iterator) {
377
- const r = handleMessage(message, emit, streamedAny);
380
+ const r = handleMessage(message, emit, streamedAny, cwd);
378
381
  if (r.streamed) streamedAny = true;
379
382
  if (r.result != null) resultText = r.result;
380
383
  }
@@ -23,6 +23,7 @@ import path from 'node:path';
23
23
  import { findAgentBin, selfMcpStdio } from '../env.js';
24
24
  import { buildCliPrompt } from './prompt.js';
25
25
  import { pushExtraArgs, FORBIDDEN } from './args.js';
26
+ import { resolveWorkdir } from '../workdir.js';
26
27
 
27
28
  // Idle timeout: re-armed on every stdout/stderr chunk, so a long run that keeps
28
29
  // streaming never trips it — only true silence does. Override with
@@ -30,7 +31,6 @@ import { pushExtraArgs, FORBIDDEN } from './args.js';
30
31
  const IDLE_MS = Number(process.env.CHATPANEL_CODEX_TIMEOUT_MS) || 180_000;
31
32
  const REASONING = process.env.CHATPANEL_CODEX_EFFORT ?? 'low'; // '' → respect config
32
33
 
33
- const SCRATCH = path.join(os.tmpdir(), 'chatpanel-codex-scratch');
34
34
 
35
35
  // Codex has no "list models" command — its model lives in CODEX_HOME/config.toml
36
36
  // (e.g. `model = "gpt-5.5"`). Surface the user's REAL configured model(s), read
@@ -49,13 +49,6 @@ export async function listModels() {
49
49
  }
50
50
  const ISO_HOME = path.join(os.homedir(), '.chatpanel', 'codex-home');
51
51
 
52
- function ensureScratch() {
53
- try {
54
- mkdirSync(SCRATCH, { recursive: true });
55
- } catch {
56
- /* best effort */
57
- }
58
- }
59
52
 
60
53
  // Build (once) an isolated CODEX_HOME that has only a link to your auth, so the
61
54
  // global skills/config don't load. Returns the path, or null on failure.
@@ -139,13 +132,12 @@ async function writeImages(images, tag) {
139
132
  }
140
133
 
141
134
  export async function chat({ messages, system, options, images }, emit, { signal } = {}) {
142
- ensureScratch();
143
135
  const tag = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
144
136
  const outFile = path.join(os.tmpdir(), `chatpanel-codex-${tag}.txt`);
145
137
  const imageFiles = await writeImages(images, tag);
146
138
  const cleanupImages = () => imageFiles.forEach((f) => unlink(f).catch(() => {}));
147
139
 
148
- const cwd = options.workingDir ? path.resolve(options.workingDir) : SCRATCH;
140
+ const cwd = resolveWorkdir(options.workingDir);
149
141
  const args = ['exec', '--json', '--skip-git-repo-check', '-o', outFile];
150
142
  // Headless exec has no human to approve actions. With MCP/browser tools armed
151
143
  // Codex would otherwise raise an approval prompt it can't show — and cancel the
@@ -23,6 +23,7 @@ import { killOnAbort } from '../proc.js';
23
23
  import { buildCliPrompt } from './prompt.js';
24
24
  import { pushExtraArgs, FORBIDDEN } from './args.js';
25
25
  import { createStreamParser, STREAM_FORMATS, stripAnsi } from './stream-formats.js';
26
+ import { resolveWorkdir } from '../workdir.js';
26
27
 
27
28
  // Write base64 data-URL images to temp files so a custom CLI can take them via
28
29
  // its configured `imageArg` template (e.g. "-i {path}", "@{path}"). Returns paths.
@@ -383,7 +384,7 @@ export async function runSpec(spec, { messages, system, options = {}, images },
383
384
  }
384
385
 
385
386
  const prompt = buildCliPrompt(messages, system);
386
- let cwd = options.workingDir ? path.resolve(options.workingDir) : null;
387
+ let cwd = resolveWorkdir(options.workingDir);
387
388
  const label = spec.label || spec.command;
388
389
  // Output dialect — resolved against the stream-format registry, so a new agent
389
390
  // brings a format by NAME instead of a new branch in this runner. Unknown /
package/src/server.js CHANGED
@@ -9,6 +9,7 @@
9
9
  // {type:'delta', text} incremental assistant text
10
10
  // {type:'tool', name, summary}
11
11
  // {type:'status'|'reasoning', text?}
12
+ // {type:'workdir', path, isDefault} where this run writes
12
13
  // {type:'done', text?} (text only if not streamed)
13
14
  // {type:'error', error}
14
15
  // POST /v1/chat/completions, /v1/completions, /v1/responses
@@ -37,6 +38,7 @@ import { connectorsFor } from './connectors.js';
37
38
  import * as custom from './engines/custom.js';
38
39
  import { installService, uninstallService, serviceStatus, restartService } from './service.js';
39
40
  import { skillIndex, listRecords, readRecord, readPackageFile, skillsHealth } from './skills.js';
41
+ import { DEFAULT_WORKSPACE, isDefaultWorkdir, resolveWorkdir, writeScopeNote } from './workdir.js';
40
42
  import { AGENT_CLIS, enrichPath, enrichAgentEnv, findAgentBin, resolveCommand } from './env.js';
41
43
  import { stripHidden } from './sanitize.js';
42
44
  import { checkForUpdate, selfUpdate } from './update.js';
@@ -64,7 +66,7 @@ import {
64
66
  // Hardcoded (not read from package.json) so it survives Bun's single-file
65
67
  // --compile, where package.json isn't on a readable FS. CI fails the publish if
66
68
  // this drifts from package.json, so the two can't silently diverge.
67
- const VERSION = '0.10.29';
69
+ const VERSION = '0.10.30';
68
70
  const HOST = process.env.CHATPANEL_BRIDGE_HOST || '127.0.0.1';
69
71
  const PORT = Number(process.env.CHATPANEL_BRIDGE_PORT) || 4319;
70
72
 
@@ -366,7 +368,13 @@ async function handleHealth(res) {
366
368
  // an older bridge simply omits it, which is what stops a newer extension assuming the
367
369
  // endpoints exist. Never let a scan failure cost the caller its health check.
368
370
  const skills = await skillsHealth().catch(() => null);
369
- json(res, 200, { ok: true, version: VERSION, agents, update, ...(skills ? { skills } : {}) });
371
+ // So Settings can show what a blank "Working directory" actually resolves to, instead
372
+ // of leaving the user to discover it from where their files did not appear.
373
+ json(res, 200, {
374
+ ok: true, version: VERSION, agents, update,
375
+ workspace: DEFAULT_WORKSPACE,
376
+ ...(skills ? { skills } : {}),
377
+ });
370
378
  }
371
379
 
372
380
  // --------------------------------------------------------------------------
@@ -480,6 +488,21 @@ async function handleChat(req, res) {
480
488
  const safeEmit = (obj) => { if (!closed) emit(obj); };
481
489
  emit({ type: 'run', id: runId });
482
490
 
491
+ // WHERE THIS RUN WILL WRITE, said before it starts. "The agent created a file and I
492
+ // cannot find it" was the single most confusing thing about a CLI agent, and the answer
493
+ // used to depend on which engine answered — the filesystem root, the home directory, or
494
+ // a temp folder the OS clears on its own schedule. Now there is one answer and it is
495
+ // announced. Additive: a client that does not know `workdir` ignores it, and the same
496
+ // information is repeated as a `status` line, which every client already renders.
497
+ {
498
+ const dir = resolveWorkdir(body.options?.workingDir);
499
+ const chosen = !isDefaultWorkdir(body.options?.workingDir);
500
+ const scope = writeScopeNote(body.agent, body.options?.permissionMode, dir);
501
+ emit({ type: 'workdir', path: dir, isDefault: !chosen, writeScope: scope || undefined });
502
+ emit({ type: 'status', text: `Working in ${dir}${chosen ? '' : ' (default)'}` });
503
+ if (scope) emit({ type: 'status', text: scope });
504
+ }
505
+
483
506
  // Browser-tools relay: when the extension sends page-tool specs, host an MCP
484
507
  // server for this turn and tell the engine to point the CLI at it.
485
508
  const options = { ...(body.options || {}) };
package/src/skills.js CHANGED
@@ -36,7 +36,7 @@
36
36
  import { readFile as fsReadFile, readdir, stat, realpath } from 'node:fs/promises';
37
37
  import { createHash } from 'node:crypto';
38
38
  import os from 'node:os';
39
- import { join, resolve, sep } from 'node:path';
39
+ import { delimiter, join, resolve, sep } from 'node:path';
40
40
  import { isSafeSkillPath, normalizeSkill, SKILL_FILE_KINDS } from './events/skill-manifest.js';
41
41
 
42
42
  const MAX_SKILL_MD = 512 * 1024; // a procedure document, not a corpus
@@ -81,8 +81,12 @@ export const AGENT_SKILL_DIRS = Object.freeze([
81
81
  * that edits another tool's configuration directory is a tool people uninstall.
82
82
  */
83
83
  export function skillRoots(env = process.env, home = os.homedir()) {
84
+ // Split on the PLATFORM's list separator, not a fixed set. Splitting on ':' everywhere
85
+ // cut "C:\\Users\\me\\skills" into "C" and "\\Users\\me\\skills" on Windows, which is the
86
+ // one platform where a drive letter makes that character part of an ordinary path.
84
87
  const extra = String(env.CHATPANEL_SKILL_DIRS || '')
85
- .split(/[:;\n]/)
88
+ .split(/\r?\n/)
89
+ .flatMap((line) => line.split(delimiter))
86
90
  .map((s) => s.trim())
87
91
  .filter(Boolean);
88
92
  return [
@@ -213,6 +217,9 @@ async function loadSkill(dir, relPath, source) {
213
217
  }
214
218
  const { meta, body } = parseFrontmatter(text);
215
219
  const hash = `sha256-${createHash('sha256').update(text).digest('hex').slice(0, 32)}`;
220
+ // relPath is an IDENTITY, not a filesystem path: it is built with '/' on every platform
221
+ // so a skill's origin.id is the same string on Windows and macOS. Do not "fix" this to
222
+ // path.sep — that would make the same skill look like two different ones per platform.
216
223
  const dirName = relPath.split('/').pop();
217
224
  const files = await packageFiles(dir);
218
225
  const skill = skillRecord({ meta, body, dirName, relPath, source, files, hash });
@@ -278,6 +285,8 @@ export async function readPackageFile(index, name, relPath) {
278
285
  const hit = index.get(String(name || ''));
279
286
  if (!hit) return { error: 'unknown skill' };
280
287
  if (!isSafeSkillPath(relPath)) return { error: 'unsafe path' };
288
+ // '/' by wire contract — the HTTP path is URL-shaped, and isSafeSkillPath already
289
+ // refuses backslashes, so a Windows-style separator never reaches here.
281
290
  const kind = String(relPath).split('/')[0];
282
291
  if (!SKILL_FILE_KINDS.includes(kind)) return { error: 'unsafe path' };
283
292
 
package/src/workdir.js ADDED
@@ -0,0 +1,108 @@
1
+ // workdir.js — where an agent's files actually go.
2
+ //
3
+ // This existed five times with four different answers, none of them told to the user:
4
+ //
5
+ // claude (CLI) cwd: null → inherits the BRIDGE's cwd, which under launchd is `/`
6
+ // claude (SDK) ~
7
+ // codex /tmp/chatpanel-codex-scratch
8
+ // antigravity /tmp/chatpanel-agy-scratch
9
+ // custom cwd: null → `/` again
10
+ //
11
+ // So "the agent created a file and I cannot find it" was not user error. Depending on
12
+ // which agent answered, the file was at the filesystem root, in the home directory, or in
13
+ // a temp folder the OS deletes on its own schedule.
14
+ //
15
+ // One default, and it is neither `/` nor a temp directory: `~/.chatpanel/workspace`.
16
+ // Persistent, predictable, obviously ChatPanel's, and somewhere a person can navigate to.
17
+ // A user who wants files elsewhere sets Working directory on the agent — which is what
18
+ // that field was always for; it just had an invisible and inconsistent fallback.
19
+ //
20
+ // The path is REPORTED, not just chosen: /health carries it so Settings can show what a
21
+ // blank field resolves to, and every run announces it, because a default nobody can see
22
+ // is the same problem in a nicer location.
23
+
24
+ import { mkdirSync } from 'node:fs';
25
+ import os from 'node:os';
26
+ import path from 'node:path';
27
+
28
+ /** Where files land when an agent has no Working directory set. */
29
+ export const DEFAULT_WORKSPACE = path.join(os.homedir(), '.chatpanel', 'workspace');
30
+
31
+ /**
32
+ * Resolve an agent's working directory, creating it if needed.
33
+ *
34
+ * Always returns a real, absolute path — never null. Inheriting the bridge's own cwd is
35
+ * what produced `/`, and a process spawned at `/` either refuses to write or writes
36
+ * somewhere nobody will look.
37
+ *
38
+ * Creation failure is not fatal: the agent may not need to write at all, and refusing to
39
+ * answer a question because a directory could not be made would be a worse failure than
40
+ * the one it prevents. The caller still gets the path, and the CLI reports its own error
41
+ * if it turns out to matter.
42
+ */
43
+ export function resolveWorkdir(workingDir) {
44
+ const dir = workingDir && String(workingDir).trim()
45
+ ? path.resolve(String(workingDir).trim())
46
+ : DEFAULT_WORKSPACE;
47
+ try {
48
+ mkdirSync(dir, { recursive: true });
49
+ } catch {
50
+ /* reported by whatever tries to write */
51
+ }
52
+ return dir;
53
+ }
54
+
55
+ /** True when this run is using the default rather than a directory the user chose. */
56
+ export function isDefaultWorkdir(workingDir) {
57
+ return !(workingDir && String(workingDir).trim());
58
+ }
59
+
60
+ /**
61
+ * How a path should read in a tool step.
62
+ *
63
+ * `path.basename` was used here, so a step said "foo.js" and left the user to guess which
64
+ * of the four possible roots it meant. Relative-to-cwd says where it is without pasting an
65
+ * absolute path into every line; anything OUTSIDE the working directory keeps its full
66
+ * path, because that is exactly the case worth noticing.
67
+ *
68
+ * `impl` is the path flavour, injected so the Windows and POSIX rules can both be tested
69
+ * on whichever machine happens to run the suite. The bridge ships to macOS, Linux AND
70
+ * Windows, and a display rule that is only ever exercised on one of them is a display rule
71
+ * that is wrong on the other two.
72
+ */
73
+ export function displayPath(filePath, cwd, impl = path) {
74
+ const p = String(filePath || '');
75
+ if (!p) return '';
76
+ if (!cwd || !impl.isAbsolute(p)) return p;
77
+ const rel = impl.relative(cwd, p);
78
+ if (!rel) return '.';
79
+ // Two ways out of the working directory, and Windows only has one of them in common
80
+ // with POSIX: `..` for a sibling, and a wholly different ROOT (another drive, a UNC
81
+ // share) for which `relative` hands back an absolute path rather than any `..` at all.
82
+ if (rel.startsWith('..') || impl.isAbsolute(rel)) return p;
83
+ return rel;
84
+ }
85
+
86
+ /**
87
+ * Whether this engine+mode confines writes to the working directory, and how to say so.
88
+ *
89
+ * Codex's `acceptEdits` maps to `--sandbox workspace-write`, which permits writes ONLY
90
+ * inside the cwd. That is correct sandboxing and it is also the most confusing failure the
91
+ * bridge produces: the agent reports that it could not write a file, the user checks that
92
+ * "auto-edit files" is on, and nothing anywhere mentions that the boundary is a DIRECTORY
93
+ * rather than a permission. It bit hardest before there was a sensible default, when a
94
+ * blank field meant a temp folder — so every attempt to edit a real project was outside
95
+ * the sandbox by construction.
96
+ *
97
+ * Stated up front rather than detected on failure: a denial does not necessarily fail the
98
+ * run (Codex reports it as a failed tool call and carries on), so there is no reliable
99
+ * error to attach an explanation to.
100
+ */
101
+ export function writeScopeNote(engineId, permissionMode, cwd) {
102
+ if (engineId !== 'codex') return '';
103
+ if (permissionMode === 'bypassPermissions') return '';
104
+ if (permissionMode !== 'acceptEdits') {
105
+ return 'Codex is read-only in this mode — set Permissions to “auto-edit files” to let it write.';
106
+ }
107
+ return `Codex may only create or edit files inside ${cwd}. To work on another project, set this agent’s Working directory to it.`;
108
+ }