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:
2026-10-02 01:10:58 +07:00
parent 2e051e23b6
commit d0535af626
44 changed files with 2515 additions and 466 deletions
+56 -5
View File
@@ -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