feat: v2.6.0 — Chat UI composer (+ menu, project files, activity panel, permission mode, Stop), git on a workspace's extra folders (#70), long paste as a file (#68), S3 server mode (experimental)

Chat UI:
- Each Claude/Codex session keeps its own chat/terminal view; Settings only
  sets how new sessions open.
- Composer + menu: upload, project files and folders (#54, also dragged from
  the Files/Changes tree), context files, recent uploads, skills, plugins.
- Activity side panel (progress, outputs, files read, uploads, memory,
  skills, connectors) with a file preview; edited-file counts per turn.
- Permission-mode chip (Shift+Tab steps, read back from the pane:
  server/lib/pane-mode.js, GET|POST /api/run/:id/permission-mode).
- Stop (Esc) while the main agent works; the status is checked per agent
  (with session_id /api/agents ignores its status filter) and re-read every
  5 s while Stop shows.
- Dropped images shown in the conversation (GET /api/run/drop/:name, the
  requester's own drop dir), full-size viewer.
- A plain-text paste over 5,000 characters is attached as pasted-text.txt,
  so no prompt reaches tmux/argv limits (#68).

Changes panel: every /api/lanes/:id/git* route takes ?source=<lane_sources
id> (sourceFolder; ENOSOURCE otherwise) and a folder picker switches the
whole panel to a workspace's extra folder (#70).

Server mode S3 (one Linux user per account) — experimental, verified with
fake sudo plus a P6 managed-settings spike on a test account:
- member tmux listing runs in the background (cached, 15 s => unknown);
- transcript snapshot copy is async, chunked, temp file + rename;
- Express Layer patch only on Express 4;
- tmux capturePane/sendKey go to the member's own server.

Docs: the multi-user design moved in as docs/plans/server-mode/README.md
(the AgentHub-Server repo is gone); S3-PLAN P6 result; LANES/API/openapi.
This commit is contained in:
2026-10-09 15:51:19 +07:00
parent ce773a3f5a
commit fa156d6a59
114 changed files with 5831 additions and 885 deletions
+18 -5
View File
@@ -15,6 +15,7 @@ const path = require("node:path");
const { db } = require("../db");
const lanesLib = require("../lib/lanes");
const { sessionWorktree, removeSessionWorktree } = require("../lib/session-worktree");
const { sourceFolder } = require("../lib/lane-sources");
const { suggestCommitMessage } = require("../lib/commit-message");
const aiTools = require("../lib/ai-tools");
const {
@@ -951,8 +952,9 @@ router.get("/:id/git", async (req, res) => {
available: true,
...(await gitFacts(t.cwd)),
// What the Changes panel's "vs base" compares against: the lane's own
// base, else the repo's default branch.
base: t.lane.base_branch || (await defaultBranch(t.cwd)),
// base, else the repo's default branch (an extra folder is another
// repo: always its own default branch).
base: (!t.source && t.lane.base_branch) || (await defaultBranch(t.cwd)),
...(t.worktree ? { worktree: t.worktree } : {}),
});
} catch {
@@ -1702,13 +1704,24 @@ function gitErrorCode(err) {
* Which tree a git route works in: the workspace's own folder, or — with
* `?session=<id>` — that session's worktree (`claude --worktree`), resolved by
* `sessionWorktree` from the session ROW (owned by this workspace, registered
* by `git worktree list`), never from a path in the request. An unresolvable
* session is a 400, not a silent fall back to the main folder: a commit or a
* discard must never land in the wrong tree.
* by `git worktree list`), or — with `?source=<id>` — one of the workspace's
* extra folders, resolved by `sourceFolder` from its `lane_sources` ROW; never
* from a path in the request. An unresolvable session (400) or source (404,
* like another workspace's row) is never a silent fall back to the main
* folder: a commit or a discard must never land in the wrong tree.
*/
async function gitTargetOr404(req, res) {
const lane = laneOr404(req, res);
if (!lane) return null;
if (req.query.source !== undefined) {
try {
const cwd = sourceFolder(lane.id, req.query.source);
return { lane, cwd, worktree: null, source: true };
} catch (err) {
res.status(404).json({ error: { code: err.code, message: err.message } });
return null;
}
}
const session = req.query.session;
if (session === undefined) return { lane, cwd: lane.cwd, worktree: null };
try {
+14 -9
View File
@@ -1,13 +1,15 @@
/**
* @file me.js
* @description `/api/me` — the signed-in account's own UI preferences and its
* first-run setup (docs/plans/onboarding). The browser keeps these in
* localStorage; this stores the same keys per ACCOUNT (`user_prefs`), so they
* follow it to any browser and the setup wizard shows once per account.
* Local mode is one account, `local`. Only the keys in `PREF_KEYS` are kept,
* each a string of at most 8 KB — this is a preferences store, not a blob
* store. Writes are same-origin and touch only the caller's own row.
* `agenthub.chatUi` ("1") opens Claude/Codex sessions as a chat (`PaneChat`).
* @description `/api/me` — the signed-in account's own UI preferences
* (`user_prefs`) and its first-run setup (docs/plans/onboarding), so they
* follow it to any browser, differ between the accounts of one dashboard, and
* the setup wizard shows once per account. Local mode is one account,
* `local`; server mode keys a row `u<id>` by the login cookie — the request
* never names an account. Only the keys in `PREF_KEYS` are kept, each a
* string of at most 8 KB, and a PUT merges into the row (`null` removes a
* key). Writes are same-origin and touch only the caller's own row.
* `agenthub.chatUi` ("1") opens Claude/Codex sessions as a chat (`PaneChat`);
* the appearance keys are `useTheme`'s own (`theme`, `agenthub.theme.dark|light`).
*
* S3 (one Linux user per account): an account's dark/light choice also becomes
* the theme in their own Claude Code settings, written as them.
@@ -92,7 +94,10 @@ router.put("/prefs", sameOriginGuard, (req, res) => {
}
if (v === null) delete prefs[k];
else if (typeof v === "string" && v.length <= MAX_VALUE) prefs[k] = v;
else return res.status(400).json({ error: { code: "EBADPREFS", message: `${k}: a short string` } });
else
return res
.status(400)
.json({ error: { code: "EBADPREFS", message: `${k}: a short string` } });
}
upsert.run(key, JSON.stringify(prefs));
if ("theme" in patch) followClaudeTheme(req, patch.theme);
+97 -3
View File
@@ -3,7 +3,9 @@
* @description HTTP routes for the dashboard's terminal-run feature. Starts,
* resumes, kills, and lists tmux-backed sessions for `claude` or `codex` (one per lane),
* streamed to the client over a dedicated WebSocket path (see
* server/websocket.js `/ws-pty/:runId`) rather than this REST surface.
* server/websocket.js `/ws-pty/:runId`) rather than this REST surface. Also
* saves files dropped on a pane or a prompt box (and serves a dropped image
* back for the chat), and reads/steps a live claude pane's permission mode.
*
* Security model:
* - Local-first dashboard. The dashboard server is expected to bind to
@@ -31,6 +33,7 @@ const { sameOriginGuard } = require("../lib/same-origin");
const aiTools = require("../lib/ai-tools");
const { resumeProxy } = require("../lib/proxy-apply");
const scope = require("../lib/scope");
const paneMode = require("../lib/pane-mode");
const router = Router();
@@ -331,6 +334,8 @@ router.get("/files", (req, res) => {
}
if (!insideOwnWorkspace(req, cwd)) return refuseFolder(res);
const q = typeof req.query.q === "string" ? req.query.q.toLowerCase() : "";
// The composer's "Add from project" also attaches folders (`dir/`).
const dirs = req.query.dirs === "1";
const SKIP_DIRS = new Set([
"node_modules",
".git",
@@ -364,7 +369,9 @@ router.get("/files", (req, res) => {
if (ent.name.startsWith(".") && ent.name !== ".env" && ent.name !== ".gitignore") continue;
if (ent.isDirectory()) {
if (SKIP_DIRS.has(ent.name)) continue;
walk(path.join(dir, ent.name), rel ? `${rel}/${ent.name}` : ent.name);
const relDir = rel ? `${rel}/${ent.name}` : ent.name;
if (dirs && (!q || relDir.toLowerCase().includes(q))) results.push(`${relDir}/`);
walk(path.join(dir, ent.name), relDir);
} else if (ent.isFile()) {
const relPath = rel ? `${rel}/${ent.name}` : ent.name;
if (!q || relPath.toLowerCase().includes(q)) {
@@ -451,7 +458,9 @@ router.post("/", (req, res) => {
return res.status(201).json(handle);
} catch (err) {
// A busy pane is a conflict with live state, not a malformed request.
if (["ERUNLIVE", "ENOTSHARED", "EMEMBERGONE", "EOWNERFOLDER", "ECWDACCOUNT"].includes(err.code)) {
if (
["ERUNLIVE", "ENOTSHARED", "EMEMBERGONE", "EOWNERFOLDER", "ECWDACCOUNT"].includes(err.code)
) {
return res.status(409).json({ error: { code: err.code, message: err.message } });
}
// Nor is a host missing tmux: the request was fine, the dependency is not
@@ -466,6 +475,91 @@ router.post("/", (req, res) => {
}
});
// A dropped image, shown back in the session's conversation. Only a name
// drop-image/drop-file made (random hex prefix, no separator) and only an image
// type; raster only and under a sandbox CSP, like `/git/raw`.
const DROP_IMAGE_TYPES = {
png: "image/png",
jpg: "image/jpeg",
jpeg: "image/jpeg",
gif: "image/gif",
webp: "image/webp",
};
router.get("/drop/:name", (req, res) => {
const name = String(req.params.name);
const m = /^[0-9a-f]{16}(?:-[A-Za-z0-9._-]+)?\.([A-Za-z]+)$/.exec(name);
const type = m && DROP_IMAGE_TYPES[m[1].toLowerCase()];
// Opened first, then checked on the descriptor (no window to swap the name
// for a link after the check): a regular file this account wrote. NOFOLLOW
// refuses a link outright; NONBLOCK keeps a FIFO from hanging the loop.
// S3: the requester's own drop dir, owned by their member account — so an
// admin viewing a member's session gets the name chip, not the image.
let fd = null;
try {
if (type) {
const { dir, member } = dropDir(req);
fd = fs.openSync(
path.join(dir, name),
fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0) | (fs.constants.O_NONBLOCK || 0)
);
const st = fs.fstatSync(fd);
const uid = member
? member.uid
: typeof process.getuid === "function"
? process.getuid()
: st.uid;
if (!st.isFile() || st.uid !== uid) {
fs.closeSync(fd);
fd = null;
}
}
} catch {
/* pruned, never dropped, or a link */
}
if (fd === null) {
return res.status(404).json({ error: { code: "ENOTFOUND", message: "no such image" } });
}
res.set({
"Content-Type": type,
"Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'; sandbox",
"X-Content-Type-Options": "nosniff",
"Cache-Control": "private, max-age=86400",
});
fs.createReadStream(null, { fd }).pipe(res);
});
// A live claude pane, or the 404/400 already sent.
function claudePane(req, res) {
const handle = scope.canSeeRun(req, req.params.id) ? runs.getRun(req.params.id) : null;
if (!handle || handle.status === "gone") {
res.status(404).json({ error: { code: "ENOTFOUND", message: "run not found" } });
return null;
}
if (handle.tool !== "claude") {
res.status(400).json({
error: { code: "EUNSUPPORTED", message: "only a claude pane has a permission mode" },
});
return null;
}
return handle;
}
router.get("/:id/permission-mode", (req, res) => {
const handle = claudePane(req, res);
if (handle) res.json({ mode: paneMode.readMode(handle.id) });
});
router.post("/:id/permission-mode", async (req, res) => {
const handle = claudePane(req, res);
if (!handle) return;
try {
res.json({ mode: await paneMode.setMode(handle.id, String(req.body?.mode ?? "")) });
} catch (err) {
const status = err.code === "EBADMODE" ? 400 : err.code ? 409 : 500;
res.status(status).json({ error: { code: err.code || "EINTERNAL", message: err.message } });
}
});
router.get("/:id", (req, res) => {
const handle = scope.canSeeRun(req, req.params.id) ? runs.getRun(req.params.id) : null;
if (!handle || handle.status === "gone") {
+149 -1
View File
@@ -357,6 +357,123 @@ router.get("/:id/stats", (req, res) => {
res.json(sessionStats(req.params.id));
});
const OUTLINE_TOOLS = [
"TaskCreate",
"TaskUpdate",
"TodoWrite",
"Write",
"Edit",
"MultiEdit",
"NotebookEdit",
"Read",
"Skill",
];
const outlineEvents = db.prepare(
`SELECT tool_name, data FROM events
WHERE session_id = ?
AND ((event_type = 'PostToolUse'
AND (tool_name IN (${OUTLINE_TOOLS.map(() => "?").join(",")})
OR (tool_name >= 'mcp__' AND tool_name < 'mcp_\`')))
OR event_type = 'UserPromptSubmit')
ORDER BY id`
);
// A file the composer attached (`/api/run/drop-*` names it), as its path is
// pasted into the prompt; and a Claude Code memory file.
const UPLOAD_RE =
/(?:^|\s)(\S*\/agenthub-drops-\d+\/[0-9a-f]{16}(?:-[A-Za-z0-9._-]+)?\.[A-Za-z0-9]+)/g;
const isUpload = (p) => /\/agenthub-drops-\d+\//.test(p);
const isMemory = (p) => /\/\.claude\/.*\/memory\//.test(p);
/** Records `p` once, newest last. */
const touch = (set, p) => {
set.delete(p);
set.add(p);
};
/** The `/:id/outline` body: the session's plan, the files it wrote or edited
* (`outputs`), what it used — the files it read, the files attached to its
* prompts (`uploads`), the memory files it read or wrote, skills and MCP
* servers — read off its successful tool calls (`PostToolUse`) and prompts.
* A `TodoWrite` replaces the plan wholesale; `TaskCreate` / `TaskUpdate` edit
* it by task id (a hook posted twice changes nothing). Lists are newest first,
* except skills and MCP servers (first use first). */
function sessionOutline(sessionId) {
let tasks = new Map();
const outputs = new Set();
const files = new Set();
const uploads = new Set();
const memory = new Set();
const skills = new Set();
const mcp = new Set();
for (const e of outlineEvents.all(sessionId, ...OUTLINE_TOOLS)) {
let d;
try {
d = JSON.parse(e.data);
} catch {
continue;
}
const input = d?.tool_input || {};
const t = e.tool_name;
const p = input.file_path || input.notebook_path;
if (!t) {
// A prompt: the paths it carries of files the composer attached.
for (const m of String(d?.prompt ?? "").matchAll(UPLOAD_RE)) touch(uploads, m[1]);
} else if (typeof p === "string" && isMemory(p)) {
touch(memory, p);
} else if (t === "Read") {
if (typeof p === "string" && !isUpload(p) && !outputs.has(p)) touch(files, p);
} else if (t === "TodoWrite" && Array.isArray(input.todos)) {
tasks = new Map(
input.todos.map((x, i) => [
String(i),
{ id: String(i), subject: String(x?.content ?? ""), status: x?.status || "pending" },
])
);
} else if (t === "TaskCreate") {
const id = d?.tool_response?.task?.id;
if (id != null && !tasks.has(String(id)))
tasks.set(String(id), {
id: String(id),
subject: String(input.subject ?? ""),
status: "pending",
});
} else if (t === "TaskUpdate") {
const task = tasks.get(String(input.taskId));
if (!task) continue;
if (input.status === "deleted") tasks.delete(task.id);
else {
if (input.status) task.status = input.status;
if (input.subject) task.subject = String(input.subject);
}
} else if (["Write", "Edit", "MultiEdit", "NotebookEdit"].includes(t)) {
if (typeof p === "string") {
touch(outputs, p);
files.delete(p); // a file it changed is an output, not just used
}
} else if (t === "Skill") {
if (typeof input.skill === "string") skills.add(input.skill);
} else {
const server = t.slice(5, t.lastIndexOf("__"));
if (server) mcp.add(server.replace(/^plugin_/, ""));
}
}
return {
progress: [...tasks.values()],
outputs: [...outputs].reverse(),
files: [...files].reverse(),
uploads: [...uploads].reverse(),
memory: [...memory].reverse(),
skills: [...skills],
mcp: [...mcp],
};
}
router.get("/:id/outline", (req, res) => {
if (!stmts.getSession.get(req.params.id)) {
return res.status(404).json({ error: { code: "NOT_FOUND", message: "Session not found" } });
}
res.json(sessionOutline(req.params.id));
});
/** The `/:id/stats` body — also frozen into a session snapshot. */
function sessionStats(sessionId) {
const totalEvents = stmts.sessionEventCount.get(sessionId)?.count ?? 0;
@@ -550,7 +667,9 @@ router.get("/:id/transcripts", async (req, res) => {
const metaPath = path.join(dir, file.replace(".jsonl", ".meta.json"));
if (fs.existsSync(metaPath)) {
try {
meta = JSON.parse(require("../lib/run-as").readFileSafe(metaPath, "utf8", { max: 1 << 20 }));
meta = JSON.parse(
require("../lib/run-as").readFileSafe(metaPath, "utf8", { max: 1 << 20 })
);
} catch {
/* ignore */
}
@@ -839,6 +958,8 @@ router.get("/:id/transcript", async (req, res) => {
if (block.type === "text" && block.text) {
content.push({ type: "text", text: truncate(block.text, 10240) });
} else if (block.type === "tool_result") {
// A line carries one tool result; an edit's +/- counts ride on it.
const diff = patchCounts(entry.toolUseResult);
content.push({
type: "tool_result",
id: block.tool_use_id || null,
@@ -849,6 +970,7 @@ router.get("/:id/transcript", async (req, res) => {
10240
),
is_error: !!block.is_error,
...(diff ? { diff } : {}),
});
}
}
@@ -891,6 +1013,8 @@ router.get("/:id/transcript", async (req, res) => {
if (entry.type === "assistant") {
if (msg.model) message.model = msg.model;
// "end_turn" closes the turn; "tool_use" means more work follows.
if (typeof msg.stop_reason === "string") message.stop_reason = msg.stop_reason;
if (msg.usage) {
message.usage = {
input_tokens: msg.usage.input_tokens || 0,
@@ -1112,6 +1236,29 @@ function codexMessage(entry, num) {
}
}
/** The +/- line counts of a Write/Edit/MultiEdit result (`toolUseResult`):
* its `structuredPatch` hunks' `+`/`-` lines, or every line of a file it
* created. Null for any other result. */
function patchCounts(r) {
if (!r || typeof r !== "object") return null;
if (Array.isArray(r.structuredPatch) && r.structuredPatch.length) {
let added = 0;
let removed = 0;
for (const h of r.structuredPatch) {
for (const l of Array.isArray(h?.lines) ? h.lines : []) {
if (typeof l !== "string") continue;
if (l.startsWith("+")) added++;
else if (l.startsWith("-")) removed++;
}
}
return { added, removed };
}
if (r.type === "create" && typeof r.content === "string") {
return { added: r.content.split("\n").length - (r.content.endsWith("\n") ? 1 : 0), removed: 0 };
}
return null;
}
function truncate(str, maxLen) {
if (!str || str.length <= maxLen) return str;
return str.slice(0, maxLen) + "[truncated]";
@@ -1128,3 +1275,4 @@ module.exports = router;
module.exports.sessionStats = sessionStats;
// Exported for unit tests — sender attribution is correctness-critical.
module.exports.classifyTranscriptSender = classifyTranscriptSender;
module.exports.patchCounts = patchCounts;