fix(panes): /tui works in panes with workspace instructions

Claude refused `/tui` ("Cannot switch renderers in this session — it has
restrictions a restart can't carry over") in every pane started with
--append-system-prompt-file, i.e. any workspace with instructions (member
panes usually have the owner's). The instructions are now written as the only
CLAUDE.md of a folder of their own (<data dir>/workspace-prompts/<id>/, a
member's prompt-<id>/ in the handoff dir via write-as), passed as one more
--add-dir with CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD=1. Verified with
Claude Code 2.1.301: the file is listed in /memory and /tui switches. Codex
is unchanged (its pointer line names the same file).
This commit is contained in:
2026-10-16 13:38:09 +07:00
parent 5e0a9b526b
commit 7c5f30df7d
7 changed files with 45 additions and 25 deletions
+2 -2
View File
@@ -1,6 +1,6 @@
# Claude Code Working Guide # Claude Code Working Guide
_Last synced: v2.8.8 (2026-10-16) — updates skip unchanged deps and the user-side tsc; opt-in event retention + Compact; ReDoc vendored under server/vendor/._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session. _Last synced: v2.8.9 (2026-10-16) — workspace instructions reach Claude as a CLAUDE.md in an --add-dir folder (not --append-system-prompt-file), so /tui works._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session.
## Project mission ## Project mission
- Maintain a reliable local-first dashboard for Claude Code session monitoring. - Maintain a reliable local-first dashboard for Claude Code session monitoring.
@@ -60,7 +60,7 @@ A **workspace** — called a **lane** in the API, CLI, database and code, which
- **The whole-file editor and the Files tab's upload are the two non-git writes on the lane file surface, and both live outside `git-ops.js` on purpose.** `git-ops.js` only touches paths `git status` reports as changed; `lane-file-write.js` may save any existing text file, so it must reuse the READ path's guards (`lanePath`, `resolveInLane`, `isSecretOnDisk`) and never be weaker than them, and it saves only over the `baseSha` it was opened at — do not let it create files or touch a binary. `lane-file-transfer.js`'s upload (`POST /api/lanes/:id/git/upload`, issue #78) is the opposite and only that: it CREATES one file (any bytes, ≤ 25 MB) under the same guards, into an existing folder, refuses a taken name (`EEXISTS`, a symlink included) and anything into or named `.git` (a hook there runs on the dashboard's next git call), and writes as the folder's member through `write-as` under S3 — do not let it overwrite, make folders or take a path from anything but `dir` + one plain name. Its `GET …/git/download` twin streams any file through `openSafe` as a `sandbox`ed attachment. The tree, expand-all and quick-open read the disk (`walkFolder`) for a folder that is not a repo. - **The whole-file editor and the Files tab's upload are the two non-git writes on the lane file surface, and both live outside `git-ops.js` on purpose.** `git-ops.js` only touches paths `git status` reports as changed; `lane-file-write.js` may save any existing text file, so it must reuse the READ path's guards (`lanePath`, `resolveInLane`, `isSecretOnDisk`) and never be weaker than them, and it saves only over the `baseSha` it was opened at — do not let it create files or touch a binary. `lane-file-transfer.js`'s upload (`POST /api/lanes/:id/git/upload`, issue #78) is the opposite and only that: it CREATES one file (any bytes, ≤ 25 MB) under the same guards, into an existing folder, refuses a taken name (`EEXISTS`, a symlink included) and anything into or named `.git` (a hook there runs on the dashboard's next git call), and writes as the folder's member through `write-as` under S3 — do not let it overwrite, make folders or take a path from anything but `dir` + one plain name. Its `GET …/git/download` twin streams any file through `openSafe` as a `sandbox`ed attachment. The tree, expand-all and quick-open read the disk (`walkFolder`) for a folder that is not a repo.
- **Server tests never touch the user's tmux.** Run them through `npm run test:server` (`scripts/test-server.js`), never a bare `node --test server/__tests__/*.test.js` from inside a lane pane — the suites create and kill real `agenthub-lane-<id>` sessions. - **Server tests never touch the user's tmux.** Run them through `npm run test:server` (`scripts/test-server.js`), never a bare `node --test server/__tests__/*.test.js` from inside a lane pane — the suites create and kill real `agenthub-lane-<id>` sessions.
- **An artifact is shared as a SNAPSHOT and installed only into the viewer's own account.** `server/lib/artifacts.js` (`/api/artifacts`): a pinned file or a skill is copied once into `<data dir>/artifacts/<random>/` and every viewer reads only that copy — `raw` serves only a file the snapshot recorded, under a `sandbox` CSP + `nosniff`; a `page` is a `https://claude.ai/…` link only. A developer sees their own rows plus shared ones; only the owner or an admin changes one. Install writes a skill into the CALLER's `~/.claude/skills/` (S3: as them through `write-as`), never over an existing one without `replace` (the old one is moved to `~/.claude/agenthub-replaced-skills/`, never deleted), and runs a plugin's `claude plugin marketplace add`/`install --scope user` as the caller, never with `-y`; an admin's switch (Settings → AI tools, `ArtifactsHubSettings`) turns installs off. An uploaded or written skill is snapshotted under the same checks (paths relative, no `..`, no secret names, zip sizes read before inflating). `POST /api/artifacts/plugin/inspect` clones a user-named https link only to read `marketplace.json`, with every credential helper OFF — never send a login with it. Do not add a route that serves a live file of another user, installs into someone else's account, or takes a plugin command from the request. - **An artifact is shared as a SNAPSHOT and installed only into the viewer's own account.** `server/lib/artifacts.js` (`/api/artifacts`): a pinned file or a skill is copied once into `<data dir>/artifacts/<random>/` and every viewer reads only that copy — `raw` serves only a file the snapshot recorded, under a `sandbox` CSP + `nosniff`; a `page` is a `https://claude.ai/…` link only. A developer sees their own rows plus shared ones; only the owner or an admin changes one. Install writes a skill into the CALLER's `~/.claude/skills/` (S3: as them through `write-as`), never over an existing one without `replace` (the old one is moved to `~/.claude/agenthub-replaced-skills/`, never deleted), and runs a plugin's `claude plugin marketplace add`/`install --scope user` as the caller, never with `-y`; an admin's switch (Settings → AI tools, `ArtifactsHubSettings`) turns installs off. An uploaded or written skill is snapshotted under the same checks (paths relative, no `..`, no secret names, zip sizes read before inflating). `POST /api/artifacts/plugin/inspect` clones a user-named https link only to read `marketplace.json`, with every credential helper OFF — never send a login with it. Do not add a route that serves a live file of another user, installs into someone else's account, or takes a plugin command from the request.
- **A workspace's instructions, extra folders and context files are lane facts, and a pane reads them only from the lane's rows.** The one addition is the workspace OWNER's own agent instructions (`user_prefs.instructions`, `PUT /api/me/instructions`, Settings → AI tools), which `launchExtras` puts first — keyed by `lanes.owner_id` (`local` in local mode), never by whoever clicked Start. `lanes.instructions` and `lane_sources` (`server/lib/lane-sources.js`) reach a pane through `launchExtras(laneId, tool)`, which `spawnRun` calls ITSELF — so neither spawn path can take them from a request — and every write is same-origin (`server/routes/lane-sources.js`). A folder must be absolute, exist, be a directory, not be or contain the OS home (`validateHomeDir`) and not be the workspace's own folder; it is the user's, and AgentHub never deletes it. An uploaded file is stored under `<data dir>/workspace-context/<lane id>/` under a name AgentHub derives (never a path from the request), and those copies are the only thing here AgentHub deletes (`ownsFile`): with their row, and with the workspace in `deleteLane`. Claude's `--add-dir` is VARIADIC, so it goes first in the argv where `--permission-mode` ends it, and the text (instructions + context file list + extra folders) is written to `<data dir>/workspace-prompts/<lane id>.md` at every spawn and passed as `--append-system-prompt-file=<path>` (one `=` argv element, so nothing in it is read as an option); Codex gets one `--add-dir` per directory and `-c developer_instructions=<JSON string>` — the text itself up to 6 KB, else a line pointing at that file. Only `claude`/`codex` take them. Never write them into the user's repo (CLAUDE.md/AGENTS.md). - **A workspace's instructions, extra folders and context files are lane facts, and a pane reads them only from the lane's rows.** The one addition is the workspace OWNER's own agent instructions (`user_prefs.instructions`, `PUT /api/me/instructions`, Settings → AI tools), which `launchExtras` puts first — keyed by `lanes.owner_id` (`local` in local mode), never by whoever clicked Start. `lanes.instructions` and `lane_sources` (`server/lib/lane-sources.js`) reach a pane through `launchExtras(laneId, tool)`, which `spawnRun` calls ITSELF — so neither spawn path can take them from a request — and every write is same-origin (`server/routes/lane-sources.js`). A folder must be absolute, exist, be a directory, not be or contain the OS home (`validateHomeDir`) and not be the workspace's own folder; it is the user's, and AgentHub never deletes it. An uploaded file is stored under `<data dir>/workspace-context/<lane id>/` under a name AgentHub derives (never a path from the request), and those copies are the only thing here AgentHub deletes (`ownsFile`): with their row, and with the workspace in `deleteLane`. Claude's `--add-dir` is VARIADIC, so it goes first in the argv where `--permission-mode` ends it, and the text (instructions + context file list + extra folders) is written at every spawn as the ONLY file `CLAUDE.md` of `<data dir>/workspace-prompts/<lane id>/` (a member's: `prompt-<lane id>/` in their handoff dir, via `write-as`), which goes in as one more `--add-dir` with `CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD=1` in the pane env — never `--append-system-prompt-file`: a custom system prompt is a launch flag Claude cannot carry over a restart, so it refused `/tui` in every pane with instructions (side effect: a CLAUDE.md in an extra folder is now read too); Codex gets one `--add-dir` per directory and `-c developer_instructions=<JSON string>` — the text itself up to 6 KB, else a line pointing at that file. Only `claude`/`codex` take them. Never write them into the user's repo (CLAUDE.md/AGENTS.md).
- **A workspace's sessions are the ones it OWNS, never the ones that ran in its folder.** `sessions.lane_id` is stamped by `bindRunSession` (`server/routes/hooks.js`) for the session a dashboard pane holds — first writer wins, a nested `claude -p` excluded — and no route sets it. A Codex/Antigravity session (their hooks are opt-in, so a pane may never learn its session) is also stamped by `claimToolSessions` (`server/lib/dashboard-runs.js`, called by both pollers and once at start): the lane of the newest same-tool dashboard run in the session's folder that was live when the session started, never a Claude session. A new Claude pane is started with `--session-id <uuid>` (`spawnRun`), so its `dashboard_runs.session_id` is known before any hook and a hook that carries no `run_id` (a hook handler older than `AGENTHUB_RUN_ID` forwarding) is still stamped from that run (`laneForSession`). Every workspace session list reads `GET /api/sessions?lane=<id>` — which also counts a still-unstamped session one of the lane's `dashboard_runs` holds, because hooks reach only the lowest-port dashboard on a shared DB and that one may be an older build that never stamps — and shows nothing else (no "other sessions in this folder"). Do not go back to listing a workspace by `cwd`, and do not guess ownership for old history. - **A workspace's sessions are the ones it OWNS, never the ones that ran in its folder.** `sessions.lane_id` is stamped by `bindRunSession` (`server/routes/hooks.js`) for the session a dashboard pane holds — first writer wins, a nested `claude -p` excluded — and no route sets it. A Codex/Antigravity session (their hooks are opt-in, so a pane may never learn its session) is also stamped by `claimToolSessions` (`server/lib/dashboard-runs.js`, called by both pollers and once at start): the lane of the newest same-tool dashboard run in the session's folder that was live when the session started, never a Claude session. A new Claude pane is started with `--session-id <uuid>` (`spawnRun`), so its `dashboard_runs.session_id` is known before any hook and a hook that carries no `run_id` (a hook handler older than `AGENTHUB_RUN_ID` forwarding) is still stamped from that run (`laneForSession`). Every workspace session list reads `GET /api/sessions?lane=<id>` — which also counts a still-unstamped session one of the lane's `dashboard_runs` holds, because hooks reach only the lowest-port dashboard on a shared DB and that one may be an older build that never stamps — and shows nothing else (no "other sessions in this folder"). Do not go back to listing a workspace by `cwd`, and do not guess ownership for old history.
- **A session's worktree is `claude --worktree`'s, resolved from the session row — and removing it never forces.** A Worktree start passes `--worktree=<server-made name>` (the `=` form: `-w [name]` eats the prompt otherwise) and Claude Code makes, populates and cleans up `<repo>/.claude/worktrees/<name>`; AgentHub only excludes `/.claude/worktrees/` at the repo root. A git route, a resume or a removal may work in a session's worktree only through `sessionWorktree` (`server/lib/session-worktree.js`: the session owned by the lane, its cwd not the lane's folder, registered by `git worktree list`) — `?session=` on the git routes answers `ENOWORKTREE` rather than falling back to the main folder. `POST /api/lanes/:id/session-worktree/remove` (same-origin, `confirm: true`, refused while a live pane holds the session) is the ONE dashboard path that deletes a worktree: `git worktree remove` with no `--force`, `git branch -d` never `-D`, a live-pid lock refused. Do not add a force, and do not let it touch a lane's own folder. - **A session's worktree is `claude --worktree`'s, resolved from the session row — and removing it never forces.** A Worktree start passes `--worktree=<server-made name>` (the `=` form: `-w [name]` eats the prompt otherwise) and Claude Code makes, populates and cleans up `<repo>/.claude/worktrees/<name>`; AgentHub only excludes `/.claude/worktrees/` at the repo root. A git route, a resume or a removal may work in a session's worktree only through `sessionWorktree` (`server/lib/session-worktree.js`: the session owned by the lane, its cwd not the lane's folder, registered by `git worktree list`) — `?session=` on the git routes answers `ENOWORKTREE` rather than falling back to the main folder. `POST /api/lanes/:id/session-worktree/remove` (same-origin, `confirm: true`, refused while a live pane holds the session) is the ONE dashboard path that deletes a worktree: `git worktree remove` with no `--force`, `git branch -d` never `-D`, a live-pid lock refused. Do not add a force, and do not let it touch a lane's own folder.
- **Opening a session never resumes it — only SENDING a message to it does.** The sidebar and a workspace home ATTACH pane 0 to a session's live tmux pane (`/workspace?lane=<id>&run=<runId>`, `attachRequest` on `LaneConsolePane`) or, for an ended one, open it in its workspace like Claude Desktop (`/workspace?lane=<id>&session=<id>`, `client/src/components/lanes/SessionReplay.tsx`: its conversation read-only plus a prompt box; the console is only hidden, CSS). A resume re-reads the whole transcript into context, so it happens only on an explicit action: a message SENT from that replay (`resumeSessionId` + `prompt`, first free slot), the transcript page's **Resume**/**Fork** (`SessionResumeButton.tsx`, behind a warning), or the ticked sessions of the **Resume interrupted sessions?** dialog (`InterruptedSessionsPrompt.tsx`, `GET /api/run/interrupted` — runs whose pane died under them; opening it resumes nothing). The analysis page `/sessions/<id>` is reached from the session view's **Analyst** button (beside Files/Changes in the right panel). Do not add a path that resumes on a click. A resume is refused while a live pane holds the session (`409 ESESSIONLIVE` with that pane's `run_id`) — open that pane instead; a fork makes a new session and is allowed. - **Opening a session never resumes it — only SENDING a message to it does.** The sidebar and a workspace home ATTACH pane 0 to a session's live tmux pane (`/workspace?lane=<id>&run=<runId>`, `attachRequest` on `LaneConsolePane`) or, for an ended one, open it in its workspace like Claude Desktop (`/workspace?lane=<id>&session=<id>`, `client/src/components/lanes/SessionReplay.tsx`: its conversation read-only plus a prompt box; the console is only hidden, CSS). A resume re-reads the whole transcript into context, so it happens only on an explicit action: a message SENT from that replay (`resumeSessionId` + `prompt`, first free slot), the transcript page's **Resume**/**Fork** (`SessionResumeButton.tsx`, behind a warning), or the ticked sessions of the **Resume interrupted sessions?** dialog (`InterruptedSessionsPrompt.tsx`, `GET /api/run/interrupted` — runs whose pane died under them; opening it resumes nothing). The analysis page `/sessions/<id>` is reached from the session view's **Analyst** button (beside Files/Changes in the right panel). Do not add a path that resumes on a click. A resume is refused while a live pane holds the session (`409 ESESSIONLIVE` with that pane's `run_id`) — open that pane instead; a fork makes a new session and is allowed.
+1 -1
View File
@@ -1598,7 +1598,7 @@ What a workspace's home page edits (`server/lib/lane-sources.js`, `server/routes
- **Memory** — read-only: the folder's `CLAUDE.md`, `.claude/CLAUDE.md`, `CLAUDE.local.md`, `AGENTS.md`, and for a Claude workspace `<CLAUDE_HOME>/projects/<encoded cwd>/memory/*.md` — `{files: [{label, path, size, mtime, truncated, content}]}`, each at most 256 KB of content. - **Memory** — read-only: the folder's `CLAUDE.md`, `.claude/CLAUDE.md`, `CLAUDE.local.md`, `AGENTS.md`, and for a Claude workspace `<CLAUDE_HOME>/projects/<encoded cwd>/memory/*.md` — `{files: [{label, path, size, mtime, truncated, content}]}`, each at most 256 KB of content.
- Every write is same-origin only (`403 EBADORIGIN`); an unknown workspace is `404 ENOLANE`, an unknown source `404 ENOSOURCE`. - Every write is same-origin only (`403 EBADORIGIN`); an unknown workspace is `404 ENOLANE`, an unknown source `404 ENOSOURCE`.
At spawn (`spawnRun`, both `POST /api/lanes/:id/start` and `POST /api/run`) the pane gets, from these rows: Claude — `--add-dir <folders…> [<context dir>]` first, then `--append-system-prompt-file=<data dir>/workspace-prompts/<lane id>.md`, a file rewritten at every spawn holding the workspace owner's own instructions (`PUT /api/me/instructions`) + the workspace's instructions + the context file list + the extra folders; Codex — one `--add-dir <dir>` per folder (and the context dir) and `-c developer_instructions=<same text as a TOML string>` (above 6 KB as the escaped TOML string, one line pointing at that file instead; nothing when that file could not be written). Any other tool gets none of it (`supported: false`). A folder or file that vanished is skipped. At spawn (`spawnRun`, both `POST /api/lanes/:id/start` and `POST /api/run`) the pane gets, from these rows: Claude — `--add-dir <folders…> [<context dir>]` first, then that workspace's instructions folder `<data dir>/workspace-prompts/<lane id>/` (a member's: `prompt-<lane id>/` in their handoff dir) as one more `--add-dir`, with `CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD=1` in the pane's env so Claude reads the `CLAUDE.md` there as memory — never `--append-system-prompt-file`: Claude cannot carry a custom system prompt over a restart and refused `/tui` in such a pane. That `CLAUDE.md` is rewritten at every spawn and holds the workspace owner's own instructions (`PUT /api/me/instructions`) + the workspace's instructions + the context file list + the extra folders; Codex — one `--add-dir <dir>` per folder (and the context dir) and `-c developer_instructions=<same text as a TOML string>` (above 6 KB as the escaped TOML string, one line pointing at that file instead; nothing when that file could not be written). Any other tool gets none of it (`supported: false`). A folder or file that vanished is skipped.
#### Projects (workspaces grouped by repository) #### Projects (workspaces grouped by repository)
+1 -1
View File
File diff suppressed because one or more lines are too long
+15 -7
View File
@@ -181,12 +181,16 @@ describe("lane-sources instructions and launch", () => {
assert.equal(lanesLib.getLane(lane.id).instructions, "- always answer in Vietnamese"); assert.equal(lanesLib.getLane(lane.id).instructions, "- always answer in Vietnamese");
}); });
it("starts a claude pane with --add-dir first and the instructions from a file", () => { it("starts a claude pane with --add-dir first and the instructions as a CLAUDE.md there", () => {
let argv = null; let argv = null;
let newSession = null;
tmux.__setExecImpl((args) => { tmux.__setExecImpl((args) => {
if (args[0] === "has-session") throw Object.assign(new Error("gone"), { status: 1 }); if (args[0] === "has-session") throw Object.assign(new Error("gone"), { status: 1 });
// The CLI rides behind `sh -c <cd; wait for a client> sh <cwd>` (pty-run.js). // The CLI rides behind `sh -c <cd; wait for a client> sh <cwd>` (pty-run.js).
if (args[0] === "new-session") argv = args.slice(args.indexOf("--") + 6); if (args[0] === "new-session") {
newSession = args;
argv = args.slice(args.indexOf("--") + 6);
}
return ""; return "";
}); });
pty.spawnRun({ laneId: lane.id, cwd: ws, initialPrompt: "-c fix it" }); pty.spawnRun({ laneId: lane.id, cwd: ws, initialPrompt: "-c fix it" });
@@ -194,11 +198,15 @@ describe("lane-sources instructions and launch", () => {
assert.equal(argv[0], "claude"); assert.equal(argv[0], "claude");
assert.equal(argv[1], "--add-dir"); assert.equal(argv[1], "--add-dir");
assert.deepEqual(argv.slice(2, 4), [extra, src.contextDir(lane.id)]); assert.deepEqual(argv.slice(2, 4), [extra, src.contextDir(lane.id)]);
assert.equal(argv[4], "--permission-mode"); // The instructions' own folder, read as memory: a custom system prompt
const flag = argv.find((a) => a.startsWith("--append-system-prompt-file=")); // (`--append-system-prompt-file`) made Claude refuse `/tui` in the pane.
const file = flag.slice("--append-system-prompt-file=".length); const dir = argv[4];
assert.ok(file.startsWith(process.env.DASHBOARD_DATA_DIR)); assert.ok(dir.startsWith(process.env.DASHBOARD_DATA_DIR));
const text = fs.readFileSync(file, "utf8"); assert.equal(argv[5], "--permission-mode");
assert.ok(!argv.some((a) => a.startsWith("--append-system-prompt")));
assert.ok(newSession.includes("CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD=1"));
assert.deepEqual(fs.readdirSync(dir), ["CLAUDE.md"]);
const text = fs.readFileSync(path.join(dir, "CLAUDE.md"), "utf8");
assert.match(text, /^- always answer in Vietnamese/); assert.match(text, /^- always answer in Vietnamese/);
assert.match(text, /pass wd\.md/); assert.match(text, /pass wd\.md/);
// A prompt that looks like flags stays one positional, never `claude -c`. // A prompt that looks like flags stays one positional, never `claude -c`.
+1 -1
View File
@@ -114,7 +114,7 @@ describe("a member pane's files live in the member's handoff dir", () => {
assert.deepEqual(x.addDirs, [ctx]); assert.deepEqual(x.addDirs, [ctx]);
assert.equal(fs.readFileSync(path.join(ctx, "spec.md"), "utf8"), "# spec\n"); assert.equal(fs.readFileSync(path.join(ctx, "spec.md"), "utf8"), "# spec\n");
assert.equal(mode(path.join(ctx, "spec.md")), 0o644); assert.equal(mode(path.join(ctx, "spec.md")), 0o644);
assert.equal(x.systemPromptFile, path.join(HANDOFF, `prompt-${laneId}.md`)); assert.equal(x.systemPromptFile, path.join(HANDOFF, `prompt-${laneId}`, "CLAUDE.md"));
assert.equal(mode(x.systemPromptFile), 0o644); assert.equal(mode(x.systemPromptFile), 0o644);
const text = fs.readFileSync(x.systemPromptFile, "utf8"); const text = fs.readFileSync(x.systemPromptFile, "utf8");
assert.match(text, /Be brief\./); assert.match(text, /Be brief\./);
+16 -9
View File
@@ -7,7 +7,7 @@
* workspace's own folder and not be or contain the OS home; an uploaded file * workspace's own folder and not be or contain the OS home; an uploaded file
* is stored by AgentHub under `<data dir>/workspace-context/<laneId>/` with a * is stored by AgentHub under `<data dir>/workspace-context/<laneId>/` with a
* name it chooses, so a request never names a path on disk. Only Claude * name it chooses, so a request never names a path on disk. Only Claude
* (`--add-dir`, `--append-system-prompt-file=`) and Codex (`--add-dir`, * (`--add-dir`, the instructions as a CLAUDE.md in a folder of their own) and Codex (`--add-dir`,
* `-c developer_instructions=`) can take them; every other tool is reported * `-c developer_instructions=`) can take them; every other tool is reported
* unsupported and its panes get nothing. The workspace OWNER's own agent * unsupported and its panes get nothing. The workspace OWNER's own agent
* instructions (`user_prefs.instructions`, Settings → My agent instructions) * instructions (`user_prefs.instructions`, Settings → My agent instructions)
@@ -184,7 +184,9 @@ function removeSource(laneId, sourceId) {
function removeLaneContext(laneId) { function removeLaneContext(laneId) {
const dir = contextDir(laneId); const dir = contextDir(laneId);
if (ownsFile(dir)) fs.rmSync(dir, { recursive: true, force: true }); if (ownsFile(dir)) fs.rmSync(dir, { recursive: true, force: true });
fs.rmSync(path.join(getDataDir(), "workspace-prompts", `${Number(laneId)}.md`), { force: true }); const prompts = path.join(getDataDir(), "workspace-prompts");
fs.rmSync(path.join(prompts, String(Number(laneId))), { recursive: true, force: true });
fs.rmSync(path.join(prompts, `${Number(laneId)}.md`), { force: true }); // before v2.8.9
} }
/** Instructions text as stored: a string of at most MAX_INSTRUCTIONS, blank ⇒ null. */ /** Instructions text as stored: a string of at most MAX_INSTRUCTIONS, blank ⇒ null. */
@@ -286,18 +288,23 @@ function launchExtras(laneId, tool) {
// A data dir that cannot be written costs the pane its instructions (all of // A data dir that cannot be written costs the pane its instructions (all of
// Claude's, Codex's past the inline budget), never its start — same as a // Claude's, Codex's past the inline budget), never its start — same as a
// vanished source. // vanished source.
// The file is a `CLAUDE.md` alone in a folder of its own: Claude reads it as
// memory from that folder (`--add-dir` + CLAUDE_CODE_ADDITIONAL_DIRECTORIES_
// CLAUDE_MD, pty-run.js), not as `--append-system-prompt-file` — a custom
// system prompt is a launch flag Claude cannot carry over a restart, so it
// refused `/tui` (switching renderer restarts the session) in every pane
// that had instructions. Codex names the same file in its pointer line.
try { try {
const sub = `prompt-${Number(laneId)}`;
if (member) { if (member) {
const file = require("./run-as").handoffWrite( const runAs = require("./run-as");
member, runAs.mkdirAs(member, path.join(runAs.handoffDir(member), sub));
`prompt-${Number(laneId)}.md`, const file = runAs.handoffWrite(member, path.join(sub, "CLAUDE.md"), systemPrompt);
systemPrompt
);
return { addDirs, systemPrompt, systemPromptFile: file }; return { addDirs, systemPrompt, systemPromptFile: file };
} }
const dir = path.join(getDataDir(), "workspace-prompts"); const dir = path.join(getDataDir(), "workspace-prompts", String(Number(laneId)));
fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
const systemPromptFile = path.join(dir, `${Number(laneId)}.md`); const systemPromptFile = path.join(dir, "CLAUDE.md");
fs.writeFileSync(systemPromptFile, systemPrompt, { mode: 0o600 }); fs.writeFileSync(systemPromptFile, systemPrompt, { mode: 0o600 });
return { addDirs, systemPrompt, systemPromptFile }; return { addDirs, systemPrompt, systemPromptFile };
} catch (err) { } catch (err) {
+9 -4
View File
@@ -202,7 +202,10 @@ function buildClaudeArgv({
// `--add-dir` is VARIADIC (`<directories...>`): it swallows every following // `--add-dir` is VARIADIC (`<directories...>`): it swallows every following
// argument up to the next option, so it goes first, where `--permission-mode` // argument up to the next option, so it goes first, where `--permission-mode`
// always ends it — after the prompt it would eat the prompt as a directory. // always ends it — after the prompt it would eat the prompt as a directory.
if (addDirs.length) argv.push("--add-dir", ...addDirs); // The instructions' own folder rides along: its CLAUDE.md is read as memory
// (see lane-sources.js launchExtras), so `/tui` can still restart the pane.
const dirs = systemPromptFile ? [...addDirs, path.dirname(systemPromptFile)] : addDirs;
if (dirs.length) argv.push("--add-dir", ...dirs);
argv.push("--permission-mode", permissionMode || "auto"); argv.push("--permission-mode", permissionMode || "auto");
// Inside a pane Claude Code sees $TMUX and opens agent-team teammates with // Inside a pane Claude Code sees $TMUX and opens agent-team teammates with
// `split-window -P -F '#{pane_id}'`. psmux ignores -F, so the id it reads // `split-window -P -F '#{pane_id}'`. psmux ignores -F, so the id it reads
@@ -220,9 +223,6 @@ function buildClaudeArgv({
// `=` form: `-w [name]` takes an OPTIONAL value and would otherwise swallow // `=` form: `-w [name]` takes an OPTIONAL value and would otherwise swallow
// the prompt as the worktree's name. // the prompt as the worktree's name.
if (worktreeName) argv.push(`--worktree=${worktreeName}`); if (worktreeName) argv.push(`--worktree=${worktreeName}`);
// From a file: the instructions can be long, and the whole argv has to fit
// in one tmux message. `=` form, so the path is never read as an option.
if (systemPromptFile) argv.push(`--append-system-prompt-file=${systemPromptFile}`);
// Puts "bypass permissions" INTO the pane's shift+tab cycle without turning // Puts "bypass permissions" INTO the pane's shift+tab cycle without turning
// it on: `claude --help` — "Enable bypassing all permission checks as an // it on: `claude --help` — "Enable bypassing all permission checks as an
// option, without it being enabled by default." Without it the cycle is only // option, without it being enabled by default." Without it the cycle is only
@@ -626,6 +626,11 @@ function spawnRun(args) {
// journals and the proxy layer `-p <name>` loads live there — and the // journals and the proxy layer `-p <name>` loads live there — and the
// tmux server's own env may predate, or never have had, CODEX_HOME. // tmux server's own env may predate, or never have had, CODEX_HOME.
...(tool === "codex" ? { CODEX_HOME: getCodexHome() } : {}), ...(tool === "codex" ? { CODEX_HOME: getCodexHome() } : {}),
// The workspace's instructions are a CLAUDE.md in an `--add-dir` folder,
// which Claude reads only with this set (lane-sources.js launchExtras).
...(entry.launcher === "claude" && systemPromptFile
? { CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD: "1" }
: {}),
// No CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN: an inline claude duplicated its // No CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN: an inline claude duplicated its
// output whenever the pane narrowed (tmux re-wraps the full-width rules and // output whenever the pane narrowed (tmux re-wraps the full-width rules and
// pushes lines into history its redraw cannot clear). The user's own // pushes lines into history its redraw cannot clear). The user's own