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:
@@ -16,6 +16,7 @@ const { ingestWorkflowsForSession } = require("../lib/workflow-ingest");
|
||||
// `liveness.probeLiveCwds` and the watchdog picks the stub up at call time.
|
||||
const liveness = require("../lib/session-liveness");
|
||||
const lanesLib = require("../lib/lanes");
|
||||
const dashboardRuns = require("../lib/dashboard-runs");
|
||||
const { broadcastLane } = require("./lanes");
|
||||
const { detect } = require("../lib/stage-detect");
|
||||
const { getPipeline } = require("../lib/pipelines");
|
||||
@@ -1293,6 +1294,29 @@ function antigravityHookInput(hookType, data) {
|
||||
|
||||
const HOOK_TOOLS = { codex: codexHookInput, antigravity: antigravityHookInput };
|
||||
|
||||
/**
|
||||
* Record which session a dashboard pane holds. The pane's CLI inherits
|
||||
* `AGENTHUB_RUN_ID` (pty-run.js) and the hook handler forwards it as `run_id`;
|
||||
* without this a run's `session_id` stays whatever it was started with (null
|
||||
* for a new session), and the sidebar could not tell that a session is live in
|
||||
* a pane. Follows `/clear` too, which starts a new session in the same pane.
|
||||
* Only a dashboard pane name is accepted, and only a run that exists. Once
|
||||
* bound, only a SessionStart that is not a fresh `startup` (a `/clear`, a
|
||||
* `/resume`) moves it: a `claude -p` the agent runs through Bash inherits
|
||||
* AGENTHUB_RUN_ID too, and its hooks must not take the pane over. That rule
|
||||
* is Claude's only: for Codex and agy it is not verified that a new session in
|
||||
* the pane and a nested one can be told apart, so their panes follow every one.
|
||||
*/
|
||||
function bindRunSession(runId, sessionId, hookType, source, tool) {
|
||||
if (typeof runId !== "string" || !/^agenthub-lane-\d+(-[1-3])?$/.test(runId)) return;
|
||||
if (typeof sessionId !== "string" || !sessionId) return;
|
||||
const row = dashboardRuns.getRun(runId);
|
||||
if (!row || row.session_id === sessionId) return;
|
||||
const reopened = hookType === "SessionStart" && source !== "startup";
|
||||
if (tool === "claude" && row.session_id && !reopened) return;
|
||||
dashboardRuns.patchRun({ id: runId, sessionId });
|
||||
}
|
||||
|
||||
router.post("/event", (req, res) => {
|
||||
let { hook_type, data } = req.body;
|
||||
if (!hook_type || !data) {
|
||||
@@ -1334,6 +1358,7 @@ router.post("/event", (req, res) => {
|
||||
|
||||
// ponytail: full lane scan per hook — fine at dozens of lanes; index by cwd prefix if lane counts grow.
|
||||
touchLaneFromHook(hook_type, data, tool);
|
||||
bindRunSession(req.body.run_id, data.session_id, hook_type, data.source, tool);
|
||||
|
||||
res.json({ ok: true, event: result });
|
||||
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* @file Routes for a workspace's own context (server/lib/lane-sources.js),
|
||||
* mounted at /api/lanes before the main lanes router:
|
||||
* `GET /:id/sources` (instructions, folders, context files, whether the tool
|
||||
* can take them), `PUT /:id/instructions`, `POST /:id/sources/folder {path}`,
|
||||
* `POST /:id/sources/file` (raw body, `X-File-Name` header, ≤ 10 MB),
|
||||
* `DELETE /:id/sources/:sourceId`, and the read-only `GET /:id/memory`. Every
|
||||
* write is same-origin only: instructions and sources reach an agent's system
|
||||
* prompt and file access, so a page on another origin may not set them.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
"use strict";
|
||||
|
||||
const express = require("express");
|
||||
const sources = require("../lib/lane-sources");
|
||||
const lanesLib = require("../lib/lanes");
|
||||
const { sameOriginGuard } = require("../lib/same-origin");
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const STATUS = { ENOLANE: 404, ENOSOURCE: 404, ETOOBIG: 413, EBADORIGIN: 403 };
|
||||
|
||||
function sendError(res, err) {
|
||||
if (err && typeof err.code === "string" && /^E[A-Z]+$/.test(err.code)) {
|
||||
return res
|
||||
.status(STATUS[err.code] || 400)
|
||||
.json({ error: { code: err.code, message: err.message } });
|
||||
}
|
||||
return res
|
||||
.status(500)
|
||||
.json({ error: { code: "EINTERNAL", message: String(err?.message || err) } });
|
||||
}
|
||||
|
||||
const laneId = (req) => Number.parseInt(req.params.id, 10);
|
||||
|
||||
/** Re-broadcast the lane row, like every other lane write does. Lazy: the
|
||||
* lanes router requires a lot, and this file loads before it. */
|
||||
function broadcast(id) {
|
||||
try {
|
||||
require("./lanes").broadcastLane(id);
|
||||
} catch {
|
||||
/* a broadcast is best-effort */
|
||||
}
|
||||
}
|
||||
|
||||
function view(id) {
|
||||
const lane = lanesLib.getLane(id);
|
||||
if (!lane) throw Object.assign(new Error("workspace not found"), { code: "ENOLANE" });
|
||||
return {
|
||||
instructions: lane.instructions || "",
|
||||
supported: sources.supports(lane.tool),
|
||||
maxFileBytes: sources.MAX_FILE_BYTES,
|
||||
maxInstructions: sources.MAX_INSTRUCTIONS,
|
||||
sources: sources.listSources(id),
|
||||
};
|
||||
}
|
||||
|
||||
router.get("/:id/sources", (req, res) => {
|
||||
try {
|
||||
res.json(view(laneId(req)));
|
||||
} catch (err) {
|
||||
sendError(res, err);
|
||||
}
|
||||
});
|
||||
|
||||
router.put("/:id/instructions", sameOriginGuard, (req, res) => {
|
||||
try {
|
||||
const id = laneId(req);
|
||||
const body = req.body || {};
|
||||
if (!("instructions" in body)) {
|
||||
throw Object.assign(new Error("instructions is required (null clears)"), {
|
||||
code: "EBADFIELD",
|
||||
});
|
||||
}
|
||||
sources.setInstructions(id, body.instructions);
|
||||
broadcast(id);
|
||||
res.json(view(id));
|
||||
} catch (err) {
|
||||
sendError(res, err);
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/:id/sources/folder", sameOriginGuard, (req, res) => {
|
||||
try {
|
||||
const id = laneId(req);
|
||||
sources.addFolder(id, (req.body || {}).path);
|
||||
res.status(201).json(view(id));
|
||||
} catch (err) {
|
||||
sendError(res, err);
|
||||
}
|
||||
});
|
||||
|
||||
router.post(
|
||||
"/:id/sources/file",
|
||||
sameOriginGuard,
|
||||
express.raw({ type: "application/octet-stream", limit: sources.MAX_FILE_BYTES }),
|
||||
(req, res) => {
|
||||
try {
|
||||
const id = laneId(req);
|
||||
let name = String(req.headers["x-file-name"] || "");
|
||||
try {
|
||||
name = decodeURIComponent(name);
|
||||
} catch {
|
||||
/* a header that is not URI-encoded is used as sent */
|
||||
}
|
||||
sources.addFile(id, name, Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0));
|
||||
res.status(201).json(view(id));
|
||||
} catch (err) {
|
||||
sendError(res, err);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
router.delete("/:id/sources/:sourceId", sameOriginGuard, (req, res) => {
|
||||
try {
|
||||
const id = laneId(req);
|
||||
sources.removeSource(id, req.params.sourceId);
|
||||
res.json(view(id));
|
||||
} catch (err) {
|
||||
sendError(res, err);
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/:id/memory", (req, res) => {
|
||||
try {
|
||||
res.json({ files: sources.memoryFiles(laneId(req)) });
|
||||
} catch (err) {
|
||||
sendError(res, err);
|
||||
}
|
||||
});
|
||||
|
||||
// A body over the cap is refused by the parser before the handler runs.
|
||||
router.use((err, _req, res, next) => {
|
||||
if (err && err.type === "entity.too.large") {
|
||||
return res.status(413).json({
|
||||
error: { code: "ETOOBIG", message: "a context file may be at most 10 MB" },
|
||||
});
|
||||
}
|
||||
return next(err);
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
Reference in New Issue
Block a user