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
+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;