@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 +1 -1
- package/src/engines/antigravity.js +2 -7
- package/src/engines/claude.js +11 -8
- package/src/engines/codex.js +2 -10
- package/src/engines/custom.js +2 -1
- package/src/server.js +25 -2
- package/src/skills.js +11 -2
- package/src/workdir.js +108 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chatpanel/bridge",
|
|
3
|
-
"version": "0.10.
|
|
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
|
-
|
|
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
|
package/src/engines/claude.js
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
}
|
package/src/engines/codex.js
CHANGED
|
@@ -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 =
|
|
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
|
package/src/engines/custom.js
CHANGED
|
@@ -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 =
|
|
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.
|
|
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
|
-
|
|
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(
|
|
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
|
+
}
|