feat(ui): a Claude-Desktop-style home for every workspace
/workspace?lane=<id> opens on the workspace's home (WorkspaceHome): a prompt box whose Start opens a new session in the first free pane slot, Recents (a live session attaches its pane, any other opens its transcript, nothing resumes), and Instructions / Memory / Context / Folder. Instructions live in lanes.instructions, extra folders and uploaded files in the new lane_sources table (copies under <data dir>/workspace-context/<lane id>/); spawnRun reads them from the lane's rows and hands Claude --add-dir + an appended system prompt file, Codex --add-dir + developer_instructions. Memory is read-only. Every dashboard pane carries AGENTHUB_RUN_ID; hooks forward it and the run records which session the pane holds (a Claude pane follows /clear and /resume but not a nested claude -p), so the sidebar and notifications open the right pane (&run=). Settings loses its Projects group; Worktrees now decides which worktrees Add folder suggests.
This commit is contained in:
@@ -299,6 +299,7 @@ function setClaudeHome(newPath) {
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
encodeCwd,
|
||||
getClaudeHome,
|
||||
getProjectsDir,
|
||||
getDataDir,
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
/**
|
||||
* @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`, `--append-system-prompt-file=`) and Codex (`--add-dir`,
|
||||
* `-c developer_instructions=`) can take them; every other tool is reported
|
||||
* unsupported and its panes get nothing.
|
||||
* @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);
|
||||
}
|
||||
|
||||
function addFolder(laneId, input) {
|
||||
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 (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);
|
||||
}
|
||||
|
||||
/** 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 });
|
||||
fs.rmSync(path.join(getDataDir(), "workspace-prompts", `${Number(laneId)}.md`), { force: true });
|
||||
}
|
||||
|
||||
function setInstructions(laneId, text) {
|
||||
laneRow(laneId);
|
||||
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`);
|
||||
}
|
||||
db.prepare("UPDATE lanes SET instructions = ? WHERE id = ?").run(value, laneId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 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);
|
||||
const files = rows.filter((r) => r.kind === "file" && fs.existsSync(r.path)).map((r) => r.path);
|
||||
const addDirs = [...folders, ...(files.length ? [contextDir(laneId)] : [])];
|
||||
const parts = [];
|
||||
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.
|
||||
try {
|
||||
const dir = path.join(getDataDir(), "workspace-prompts");
|
||||
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
const systemPromptFile = path.join(dir, `${Number(laneId)}.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 {
|
||||
const st = fs.statSync(file);
|
||||
if (!st.isFile()) return null;
|
||||
const truncated = st.size > MAX_MEMORY_BYTES;
|
||||
const fd = fs.openSync(file, "r");
|
||||
try {
|
||||
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,
|
||||
addFile,
|
||||
removeSource,
|
||||
removeLaneContext,
|
||||
setInstructions,
|
||||
launchExtras,
|
||||
codexInstructions,
|
||||
tomlString,
|
||||
INLINE_PROMPT_BYTES,
|
||||
memoryFiles,
|
||||
safeName,
|
||||
};
|
||||
+19
-2
@@ -357,7 +357,20 @@ function setProvisioningFacts(id, facts = {}) {
|
||||
}
|
||||
|
||||
function deleteLane(id) {
|
||||
return db.prepare("DELETE FROM lanes WHERE id = ?").run(id).changes > 0;
|
||||
const gone = db.prepare("DELETE FROM lanes WHERE id = ?").run(id).changes > 0;
|
||||
// Its `lane_sources` rows cascade; AgentHub's own copies of its uploaded
|
||||
// context files go with them. A folder source is the user's and stays.
|
||||
// Lazy: lane-sources.js reads the db directly and must not load at import.
|
||||
// The row is already gone: a copy that will not delete is logged, not a
|
||||
// failed remove.
|
||||
if (gone) {
|
||||
try {
|
||||
require("./lane-sources").removeLaneContext(id);
|
||||
} catch (err) {
|
||||
console.warn(`[lanes] workspace ${id}: context files not removed:`, err.message);
|
||||
}
|
||||
}
|
||||
return gone;
|
||||
}
|
||||
|
||||
/** Slots currently held by a lane, ascending. Input to the slot allocator. */
|
||||
@@ -755,8 +768,12 @@ function lanePayload(lane, ageSec = null) {
|
||||
const inferredIdx = inferred ? phaseIdx(pipeline, inferred) : -1;
|
||||
const view = inferredIdx === -1 ? lane : { ...lane, stage: inferred };
|
||||
const nodes = nodeStates(pipeline, view);
|
||||
// Up to 16 KB per lane, on every list and broadcast; the home reads it from
|
||||
// `GET /api/lanes/:id/sources` instead.
|
||||
const row = { ...lane };
|
||||
delete row.instructions;
|
||||
return {
|
||||
...lane,
|
||||
...row,
|
||||
pipeline_name: pipeline.name,
|
||||
pipeline_skill: pipeline.skill || "",
|
||||
pipeline_nodes:
|
||||
|
||||
+56
-5
@@ -91,9 +91,34 @@ function makeErr(code, message) {
|
||||
* interactive afterward (unlike `-p`, which forces print-and-exit and closes
|
||||
* stdin).
|
||||
*/
|
||||
function buildClaudeArgv({ model, permissionMode, effort, resumeSessionId, initialPrompt }) {
|
||||
/**
|
||||
* A prompt as an argv element no CLI can read as an option: one that starts
|
||||
* with "-" (`-c fix it`) would otherwise be parsed as flags — `claude -c`
|
||||
* continues the last conversation, `-p` turns the pane into print mode. A
|
||||
* leading space keeps it a positional and changes nothing the model reads.
|
||||
*/
|
||||
function promptArg(p) {
|
||||
return p.startsWith("-") ? ` ${p}` : p;
|
||||
}
|
||||
|
||||
function buildClaudeArgv({
|
||||
model,
|
||||
permissionMode,
|
||||
effort,
|
||||
resumeSessionId,
|
||||
initialPrompt,
|
||||
addDirs = [],
|
||||
systemPromptFile = null,
|
||||
}) {
|
||||
const argv = ["claude"];
|
||||
// `--add-dir` is VARIADIC (`<directories...>`): it swallows every following
|
||||
// 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.
|
||||
if (addDirs.length) argv.push("--add-dir", ...addDirs);
|
||||
argv.push("--permission-mode", permissionMode || "auto");
|
||||
// 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
|
||||
// it on: `claude --help` — "Enable bypassing all permission checks as an
|
||||
// option, without it being enabled by default." Without it the cycle is only
|
||||
@@ -104,7 +129,7 @@ function buildClaudeArgv({ model, permissionMode, effort, resumeSessionId, initi
|
||||
if (model) argv.push("--model", model);
|
||||
if (effort && EFFORT_LEVELS.has(effort)) argv.push("--effort", effort);
|
||||
if (resumeSessionId) argv.push("--resume", resumeSessionId);
|
||||
if (initialPrompt) argv.push(initialPrompt);
|
||||
if (initialPrompt) argv.push(promptArg(initialPrompt));
|
||||
return argv;
|
||||
}
|
||||
|
||||
@@ -124,6 +149,9 @@ function buildCodexArgv({
|
||||
bypass,
|
||||
initialPrompt,
|
||||
routerProfile,
|
||||
addDirs = [],
|
||||
systemPrompt = null,
|
||||
systemPromptFile = null,
|
||||
}) {
|
||||
const argv = ["codex"];
|
||||
// Before the subcommand and before the prompt: `-p` layers
|
||||
@@ -141,7 +169,15 @@ function buildCodexArgv({
|
||||
if (approval) argv.push("-a", approval);
|
||||
}
|
||||
if (effort && CODEX_EFFORTS.has(effort)) argv.push("-c", `model_reasoning_effort=${effort}`);
|
||||
if (initialPrompt) argv.push(initialPrompt);
|
||||
for (const dir of addDirs) argv.push("--add-dir", dir);
|
||||
// `-c` parses its value as TOML; a JSON string literal is a valid TOML basic
|
||||
// string, so any text — quotes, newlines — arrives as one string.
|
||||
if (systemPrompt) {
|
||||
const { codexInstructions, tomlString } = require("./lane-sources");
|
||||
const text = codexInstructions(systemPrompt, systemPromptFile);
|
||||
if (text) argv.push("-c", `developer_instructions=${tomlString(text)}`);
|
||||
}
|
||||
if (initialPrompt) argv.push(promptArg(initialPrompt));
|
||||
return argv;
|
||||
}
|
||||
|
||||
@@ -159,9 +195,10 @@ function buildCatalogArgv(entry, bin, { model, resumeLast, bypass, initialPrompt
|
||||
if (resumeLast && entry.resume) argv.push(...entry.resume);
|
||||
if (bypass && entry.bypass) argv.push(...entry.bypass);
|
||||
const p = entry.prompt;
|
||||
if (initialPrompt && p.mode === "flag") argv.push(p.flag, initialPrompt);
|
||||
if (initialPrompt && p.mode === "flag") argv.push(p.flag, promptArg(initialPrompt));
|
||||
if (initialPrompt && p.mode === "argv")
|
||||
argv.push(...(p.separator ? [p.separator] : []), initialPrompt);
|
||||
// A `--` separator already ends the options; without one, guard the prompt.
|
||||
argv.push(...(p.separator ? [p.separator, initialPrompt] : [promptArg(initialPrompt)]));
|
||||
return argv;
|
||||
}
|
||||
|
||||
@@ -274,8 +311,18 @@ function spawnRun(args) {
|
||||
|
||||
const id = runIdForLane(laneId, slot);
|
||||
const startedAt = Date.now();
|
||||
// The workspace's own folders, context files and instructions — read from
|
||||
// its rows here, the one place both spawn paths pass through, never from a
|
||||
// request (server/lib/lane-sources.js).
|
||||
const { addDirs, systemPrompt, systemPromptFile } = require("./lane-sources").launchExtras(
|
||||
laneId,
|
||||
tool
|
||||
);
|
||||
const argv = buildArgv(
|
||||
{
|
||||
addDirs,
|
||||
systemPrompt,
|
||||
systemPromptFile,
|
||||
tool,
|
||||
model,
|
||||
permissionMode,
|
||||
@@ -336,6 +383,10 @@ function spawnRun(args) {
|
||||
// node`) dies there without the `node` the user's shell would have found.
|
||||
const env = {
|
||||
AGENTHUB_LANE_ID: String(laneId),
|
||||
// Which pane this is: the CLI's hooks inherit it, so the dashboard learns
|
||||
// which session each pane holds (routes/hooks.js → bindRunSession) — the
|
||||
// sidebar opens a session's own pane by it.
|
||||
AGENTHUB_RUN_ID: id,
|
||||
...(process.env.PATH ? { PATH: process.env.PATH } : {}),
|
||||
...desktopEnv(),
|
||||
// A codex pane must read the same home the dashboard reads — its rollout
|
||||
|
||||
Reference in New Issue
Block a user