7c5f30df7d
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).
416 lines
16 KiB
JavaScript
416 lines
16 KiB
JavaScript
/**
|
|
* @file A workspace's own context, like a Claude Desktop project's: its
|
|
* Instructions (`lanes.instructions`), extra Folders and uploaded Context files
|
|
* (`lane_sources`), its read-only Memory, and what all of it adds to a pane at
|
|
* spawn (`launchExtras`). Everything a spawn reads comes from these rows, never
|
|
* from a request. An extra folder must exist, be a directory, not be the
|
|
* 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
|
|
* name it chooses, so a request never names a path on disk. Only Claude
|
|
* (`--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
|
|
* unsupported and its panes get nothing. The workspace OWNER's own agent
|
|
* instructions (`user_prefs.instructions`, Settings → My agent instructions)
|
|
* go first, before the workspace's.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
"use strict";
|
|
|
|
const fs = require("fs");
|
|
const path = require("path");
|
|
const crypto = require("crypto");
|
|
const { db } = require("../db");
|
|
const { getDataDir, getClaudeHome, encodeCwd } = require("./claude-home");
|
|
const { validateHomeDir } = require("./settings-env");
|
|
|
|
const SUPPORTED_TOOLS = new Set(["claude", "codex"]);
|
|
const MAX_INSTRUCTIONS = 16_000;
|
|
const MAX_FILE_BYTES = 10 * 1024 * 1024;
|
|
const MAX_SOURCES = 50;
|
|
const MAX_MEMORY_BYTES = 256 * 1024;
|
|
// The whole `tmux new-session … -- argv` must fit in one tmux message (16 KB
|
|
// on tmux 3.4, `command too long` past it), with PATH and the env beside it.
|
|
// Claude reads the prompt from a file instead; Codex has no file form for
|
|
// developer instructions, so past this it gets a pointer to the file.
|
|
const INLINE_PROMPT_BYTES = 6 * 1024;
|
|
|
|
const fail = (code, message) => Object.assign(new Error(message), { code });
|
|
|
|
function laneRow(laneId) {
|
|
const lane = db.prepare("SELECT id, cwd, tool, instructions FROM lanes WHERE id = ?").get(laneId);
|
|
if (!lane) throw fail("ENOLANE", "workspace not found");
|
|
return lane;
|
|
}
|
|
|
|
function contextRoot() {
|
|
return path.join(getDataDir(), "workspace-context");
|
|
}
|
|
|
|
function contextDir(laneId) {
|
|
return path.join(contextRoot(), String(Number(laneId)));
|
|
}
|
|
|
|
function supports(tool) {
|
|
return SUPPORTED_TOOLS.has(tool || "claude");
|
|
}
|
|
|
|
function listSources(laneId) {
|
|
laneRow(laneId);
|
|
return db
|
|
.prepare(
|
|
"SELECT id, kind, path, name, size, added_at FROM lane_sources WHERE lane_id = ? ORDER BY id ASC"
|
|
)
|
|
.all(laneId)
|
|
.map((r) => ({ ...r, missing: !fs.existsSync(r.path) }));
|
|
}
|
|
|
|
function assertRoom(laneId) {
|
|
const count = db
|
|
.prepare("SELECT count(*) AS c FROM lane_sources WHERE lane_id = ?")
|
|
.get(laneId).c;
|
|
if (count >= MAX_SOURCES)
|
|
throw fail("ETOOMANY", `a workspace holds at most ${MAX_SOURCES} sources`);
|
|
}
|
|
|
|
function insertSource(laneId, kind, p, name, size) {
|
|
assertRoom(laneId);
|
|
const info = db
|
|
.prepare(
|
|
"INSERT INTO lane_sources (lane_id, kind, path, name, size, added_at) VALUES (?, ?, ?, ?, ?, ?)"
|
|
)
|
|
.run(laneId, kind, p, name, size ?? null, new Date().toISOString());
|
|
return db.prepare("SELECT * FROM lane_sources WHERE id = ?").get(info.lastInsertRowid);
|
|
}
|
|
|
|
/** `allowed(realPath)`: the caller's own boundary (a developer's home). */
|
|
function addFolder(laneId, input, { allowed = () => true } = {}) {
|
|
const lane = laneRow(laneId);
|
|
if (typeof input !== "string" || !input.trim()) throw fail("EBADPATH", "path is required");
|
|
let resolved;
|
|
try {
|
|
resolved = fs.realpathSync(validateHomeDir("a workspace folder", input));
|
|
} catch (err) {
|
|
if (err.code === "EBADHOME") throw err;
|
|
throw fail("EBADPATH", err.message);
|
|
}
|
|
let laneCwd = lane.cwd;
|
|
try {
|
|
laneCwd = fs.realpathSync(lane.cwd);
|
|
} catch {
|
|
/* a vanished workspace folder still has its recorded path */
|
|
}
|
|
if (!allowed(resolved)) throw fail("EFOLDER", "an extra folder must be inside your own folder");
|
|
if (resolved === laneCwd) throw fail("EDUP", "that is the workspace's own folder");
|
|
const dup = db
|
|
.prepare("SELECT 1 FROM lane_sources WHERE lane_id = ? AND kind = 'folder' AND path = ?")
|
|
.get(laneId, resolved);
|
|
if (dup) throw fail("EDUP", "that folder is already a source");
|
|
return insertSource(laneId, "folder", resolved, path.basename(resolved) || resolved, null);
|
|
}
|
|
|
|
/**
|
|
* An extra folder's directory for the git routes (`?source=<id>`): only a
|
|
* `folder` row of THIS workspace that is still a directory — the request names
|
|
* a row id, never a path.
|
|
* @throws ENOSOURCE otherwise.
|
|
*/
|
|
function sourceFolder(laneId, sourceId) {
|
|
const row = /^\d+$/.test(String(sourceId))
|
|
? db
|
|
.prepare("SELECT path FROM lane_sources WHERE id = ? AND lane_id = ? AND kind = 'folder'")
|
|
.get(Number(sourceId), laneId)
|
|
: null;
|
|
let dir = null;
|
|
try {
|
|
dir = row && fs.statSync(row.path).isDirectory() ? fs.realpathSync(row.path) : null;
|
|
} catch {
|
|
/* gone since it was added */
|
|
}
|
|
if (!dir) throw fail("ENOSOURCE", "that folder is not one of this workspace's folders");
|
|
return dir;
|
|
}
|
|
|
|
/** A file name AgentHub can store: no directory part, no control or path characters. */
|
|
function safeName(raw) {
|
|
const base = path.basename(String(raw || "")).replace(/[^\p{L}\p{N}._ -]/gu, "_");
|
|
const trimmed = base
|
|
.replace(/^[.\s]+/, "")
|
|
.trim()
|
|
.slice(0, 120);
|
|
return trimmed || "file";
|
|
}
|
|
|
|
function addFile(laneId, rawName, buf) {
|
|
laneRow(laneId);
|
|
if (!Buffer.isBuffer(buf) || buf.length === 0) throw fail("EEMPTY", "the file is empty");
|
|
if (buf.length > MAX_FILE_BYTES) {
|
|
throw fail("ETOOBIG", `a context file may be at most ${MAX_FILE_BYTES / 1024 / 1024} MB`);
|
|
}
|
|
// Before the write: a refused upload must leave nothing in the dir the
|
|
// agent is handed as --add-dir.
|
|
assertRoom(laneId);
|
|
const dir = contextDir(laneId);
|
|
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
const name = safeName(rawName);
|
|
const ext = path.extname(name);
|
|
const stem = name.slice(0, name.length - ext.length);
|
|
let file = path.join(dir, name);
|
|
for (let n = 2; fs.existsSync(file); n++) {
|
|
const tag = n <= 99 ? `(${n})` : crypto.randomBytes(4).toString("hex");
|
|
file = path.join(dir, `${stem} ${tag}${ext}`);
|
|
}
|
|
fs.writeFileSync(file, buf, { mode: 0o600, flag: "wx" });
|
|
return insertSource(laneId, "file", file, path.basename(file), buf.length);
|
|
}
|
|
|
|
/** True when `p` is inside AgentHub's own context store — the only files it may delete. */
|
|
function ownsFile(p) {
|
|
const root = contextRoot() + path.sep;
|
|
return path.resolve(p).startsWith(root);
|
|
}
|
|
|
|
function removeSource(laneId, sourceId) {
|
|
laneRow(laneId);
|
|
const row = db
|
|
.prepare("SELECT * FROM lane_sources WHERE id = ? AND lane_id = ?")
|
|
.get(Number(sourceId), laneId);
|
|
if (!row) throw fail("ENOSOURCE", "source not found");
|
|
// A folder is the user's; only AgentHub's own copy of an uploaded file goes.
|
|
if (row.kind === "file" && ownsFile(row.path)) fs.rmSync(row.path, { force: true });
|
|
db.prepare("DELETE FROM lane_sources WHERE id = ?").run(row.id);
|
|
}
|
|
|
|
/** Drops AgentHub's context copies of a deleted workspace (its rows cascade). */
|
|
function removeLaneContext(laneId) {
|
|
const dir = contextDir(laneId);
|
|
if (ownsFile(dir)) fs.rmSync(dir, { recursive: true, 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. */
|
|
function cleanInstructions(text) {
|
|
if (text !== null && typeof text !== "string") {
|
|
throw fail("EBADFIELD", "instructions must be a string or null");
|
|
}
|
|
const value = text === null || !text.trim() ? null : text.replace(/\r\n/g, "\n");
|
|
if (value && value.length > MAX_INSTRUCTIONS) {
|
|
throw fail("ETOOBIG", `instructions may be at most ${MAX_INSTRUCTIONS} characters`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function setInstructions(laneId, text) {
|
|
laneRow(laneId);
|
|
const value = cleanInstructions(text);
|
|
db.prepare("UPDATE lanes SET instructions = ? WHERE id = ?").run(value, laneId);
|
|
}
|
|
|
|
function setUserInstructions(userKey, text) {
|
|
const value = cleanInstructions(text);
|
|
db.prepare(
|
|
`INSERT INTO user_prefs (user_key, instructions) VALUES (?, ?)
|
|
ON CONFLICT(user_key) DO UPDATE SET instructions = excluded.instructions,
|
|
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')`
|
|
).run(userKey, value);
|
|
}
|
|
|
|
function getUserInstructions(userKey) {
|
|
return (
|
|
db.prepare("SELECT instructions FROM user_prefs WHERE user_key = ?").get(userKey)
|
|
?.instructions || null
|
|
);
|
|
}
|
|
|
|
/** The owner's own instructions: `local` in local mode, `u<owner_id>` in server mode. */
|
|
function ownerInstructions(ownerId) {
|
|
if (!require("./auth").isServerMode()) return getUserInstructions("local");
|
|
return ownerId ? getUserInstructions(`u${ownerId}`) : null;
|
|
}
|
|
|
|
/**
|
|
* What a pane of this workspace is started with, read from its rows: the
|
|
* extra folders and (when any file was uploaded) the context dir to allow, and
|
|
* the text to append to the system prompt — the instructions, then the list of
|
|
* context files so the agent knows they exist. Sources that vanished are
|
|
* skipped, not fatal: a deleted folder must not stop the workspace starting.
|
|
*/
|
|
function launchExtras(laneId, tool) {
|
|
const empty = { addDirs: [], systemPrompt: null, systemPromptFile: null };
|
|
if (!supports(tool)) return empty;
|
|
const lane = db.prepare("SELECT instructions, owner_id FROM lanes WHERE id = ?").get(laneId);
|
|
if (!lane) return empty;
|
|
const rows = db
|
|
.prepare("SELECT kind, path FROM lane_sources WHERE lane_id = ? ORDER BY id ASC")
|
|
.all(laneId);
|
|
const isDir = (p) => {
|
|
try {
|
|
return fs.statSync(p).isDirectory();
|
|
} catch {
|
|
return false;
|
|
}
|
|
};
|
|
const folders = rows.filter((r) => r.kind === "folder" && isDir(r.path)).map((r) => r.path);
|
|
let files = rows.filter((r) => r.kind === "file" && fs.existsSync(r.path)).map((r) => r.path);
|
|
// S3: a member's pane cannot reach the dashboard's data dir — the context
|
|
// files and the prompt are copied into the member's handoff dir instead.
|
|
const member = require("./run-as").accountForLane(laneId);
|
|
let ctx = contextDir(laneId);
|
|
if (member && files.length) {
|
|
// Written AS the member (`write-as`): the dashboard never writes in a home.
|
|
const runAs = require("./run-as");
|
|
const sub = `context-${Number(laneId)}`;
|
|
ctx = path.join(runAs.handoffDir(member), sub);
|
|
runAs.removeAs(member, ctx);
|
|
runAs.mkdirAs(member, ctx);
|
|
files = files.map((f) =>
|
|
runAs.handoffWrite(member, path.join(sub, path.basename(f)), fs.readFileSync(f))
|
|
);
|
|
}
|
|
const addDirs = [...folders, ...(files.length ? [ctx] : [])];
|
|
const parts = [];
|
|
const mine = ownerInstructions(lane.owner_id);
|
|
if (mine) parts.push(mine);
|
|
if (lane.instructions) parts.push(lane.instructions);
|
|
if (files.length) {
|
|
parts.push(
|
|
"Reference files for this workspace — read them when they are relevant:\n" +
|
|
files.map((f) => `- ${f}`).join("\n")
|
|
);
|
|
}
|
|
if (folders.length) {
|
|
parts.push("Additional folders you may use:\n" + folders.map((f) => `- ${f}`).join("\n"));
|
|
}
|
|
if (!parts.length) return { addDirs, systemPrompt: null, systemPromptFile: null };
|
|
const systemPrompt = parts.join("\n\n");
|
|
// Written at every spawn, so the pane reads what the rows say now.
|
|
// 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
|
|
// 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 {
|
|
const sub = `prompt-${Number(laneId)}`;
|
|
if (member) {
|
|
const runAs = require("./run-as");
|
|
runAs.mkdirAs(member, path.join(runAs.handoffDir(member), sub));
|
|
const file = runAs.handoffWrite(member, path.join(sub, "CLAUDE.md"), systemPrompt);
|
|
return { addDirs, systemPrompt, systemPromptFile: file };
|
|
}
|
|
const dir = path.join(getDataDir(), "workspace-prompts", String(Number(laneId)));
|
|
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
const systemPromptFile = path.join(dir, "CLAUDE.md");
|
|
fs.writeFileSync(systemPromptFile, systemPrompt, { mode: 0o600 });
|
|
return { addDirs, systemPrompt, systemPromptFile };
|
|
} catch (err) {
|
|
console.warn(`[lane-sources] workspace ${laneId}: prompt file not written:`, err.message);
|
|
return { addDirs, systemPrompt, systemPromptFile: null };
|
|
}
|
|
}
|
|
|
|
/** `text` as a TOML basic string: JSON's escapes are TOML's, except that TOML
|
|
* also refuses a raw DEL, which JSON.stringify leaves as is. */
|
|
function tomlString(text) {
|
|
return JSON.stringify(text).replace(/\x7f/g, "\\u007f");
|
|
}
|
|
|
|
/** The developer instructions Codex gets inline: the text itself while it is
|
|
* small, else one line pointing at the file (see INLINE_PROMPT_BYTES), or
|
|
* null when it is too long and no file was written. */
|
|
function codexInstructions(systemPrompt, systemPromptFile) {
|
|
// Measured as sent: escaping can double the text (quotes, newlines) or more.
|
|
if (Buffer.byteLength(tomlString(systemPrompt)) <= INLINE_PROMPT_BYTES) return systemPrompt;
|
|
if (!systemPromptFile) return null;
|
|
return `This workspace's instructions and reference files are in ${systemPromptFile} — read that file before anything else and follow it.`;
|
|
}
|
|
|
|
function readMemoryFile(label, file) {
|
|
try {
|
|
if (!fs.statSync(file).isFile()) return null; // no blocking open on a FIFO
|
|
// S3: a file in a member's folder is read only when the OPEN file is a
|
|
// regular file that member owns — a link they planted to /etc/shadow or
|
|
// another member's login is never read with the dashboard's rights.
|
|
const fd = require("./run-as").openSafe(file);
|
|
try {
|
|
const st = fs.fstatSync(fd);
|
|
if (!st.isFile()) return null;
|
|
const truncated = st.size > MAX_MEMORY_BYTES;
|
|
const buf = Buffer.alloc(Math.min(st.size, MAX_MEMORY_BYTES));
|
|
fs.readSync(fd, buf, 0, buf.length, 0);
|
|
return {
|
|
label,
|
|
path: file,
|
|
size: st.size,
|
|
mtime: st.mtime.toISOString(),
|
|
truncated,
|
|
content: buf.toString("utf8"),
|
|
};
|
|
} finally {
|
|
fs.closeSync(fd);
|
|
}
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The memory a workspace's agent already reads, read-only: the folder's
|
|
* CLAUDE.md / AGENTS.md family, and (Claude) its auto-memory directory
|
|
* `<CLAUDE_HOME>/projects/<encoded cwd>/memory/`. Every path is derived from
|
|
* the lane row; nothing is written.
|
|
*/
|
|
function memoryFiles(laneId) {
|
|
const lane = laneRow(laneId);
|
|
const out = [];
|
|
const add = (label, file) => {
|
|
const f = readMemoryFile(label, file);
|
|
if (f) out.push(f);
|
|
};
|
|
add("CLAUDE.md", path.join(lane.cwd, "CLAUDE.md"));
|
|
add(".claude/CLAUDE.md", path.join(lane.cwd, ".claude", "CLAUDE.md"));
|
|
add("CLAUDE.local.md", path.join(lane.cwd, "CLAUDE.local.md"));
|
|
add("AGENTS.md", path.join(lane.cwd, "AGENTS.md"));
|
|
if ((lane.tool || "claude") === "claude") {
|
|
const dir = path.join(getClaudeHome(), "projects", encodeCwd(lane.cwd), "memory");
|
|
let names = [];
|
|
try {
|
|
names = fs
|
|
.readdirSync(dir)
|
|
.filter((n) => n.endsWith(".md"))
|
|
.sort();
|
|
} catch {
|
|
/* no auto memory yet */
|
|
}
|
|
for (const n of names.slice(0, 50)) add(`memory/${n}`, path.join(dir, n));
|
|
}
|
|
return out;
|
|
}
|
|
|
|
module.exports = {
|
|
SUPPORTED_TOOLS,
|
|
MAX_FILE_BYTES,
|
|
MAX_INSTRUCTIONS,
|
|
supports,
|
|
contextDir,
|
|
listSources,
|
|
addFolder,
|
|
sourceFolder,
|
|
addFile,
|
|
removeSource,
|
|
removeLaneContext,
|
|
setInstructions,
|
|
launchExtras,
|
|
setUserInstructions,
|
|
getUserInstructions,
|
|
codexInstructions,
|
|
tomlString,
|
|
INLINE_PROMPT_BYTES,
|
|
memoryFiles,
|
|
safeName,
|
|
};
|