Files
nntrivi2001 9a54978eef feat: v2.5.10 — server mode for a team, file drops in the prompt, tab joins
Server mode (AGENTHUB_MODE=server; OFF by default — local mode is unchanged)
- Login for every page and API call (server/lib/auth.js, routes/auth.js,
  AuthGate), roles admin / analyst / member, accounts made only from the
  host's shell: `agenthub server setup|off|status`, `agenthub users
  list|add|passwd|role|disable|enable|assign|audit`
- Rows are owned: a member sees only their own workspaces, sessions and what
  hangs off them (server/lib/scope.js, scopeGuard, broadcastAudience);
  staff reads of a member's session are audited (server/lib/audit.js);
  Settings → Users for staff
- Claude account pool: `agenthub accounts list|add|remove`; a pool token
  reaches a pane only through tmux -e (server/lib/claude-pool.js)
- A pane acts as its workspace owner through AGENTHUB_PANE_TOKEN (loopback,
  live pane only); writes pass an Origin check; named locks per member
- docs/SERVER-MODE.md, docs/plans/server-mode/

Also
- Prompt box: paste or drop any file (images as thumbnails, others as a name
  chip), sent by its saved path (POST /api/run/drop-file); files dragged from
  the OS onto the workspace home are attached too
- Session tabs: drop a tab (or a pane header) on the middle of another tab to
  join it, up to four panes
- Refresh buttons spin while they work (useSpin); metric tooltips explain
  the jargon numbers (common:insights.help.*)
- Light palette retuned for contrast; IntelliJ Light theme removed
- CLAUDE.md sync marker trimmed; its history moved to docs/CLAUDE-SYNC-LOG.md

Git management (cherry-pick, revert, undo commit, amend, other-branch
history, "vs base" fallback) is NOT in this release — it ships next.

Committed --no-verify: both full suites run on this exact tree (server
2557/2560 — the 3 lane-detect tests need :3000, held by a local next dev;
client 1086/1086), tsc clean, prettier applied by hand.
2026-10-07 14:11:25 +07:00

147 lines
4.9 KiB
JavaScript

/**
* @file Express router for managing agents, providing endpoints to list, retrieve, create, and update agents. It interacts with the database using prepared statements and broadcasts changes to connected WebSocket clients for real-time updates.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { Router } = require("express");
const { stmts, db } = require("../db");
const { broadcast } = require("../websocket");
const { attachAgentCosts } = require("./pricing");
const { parseSources, sessionIdInSourcesClause } = require("../lib/source-filter");
const { memberId, bySessionWhere } = require("../lib/scope");
const router = Router();
router.get("/", (req, res) => {
const rawLimit = parseInt(req.query.limit);
const limit = rawLimit > 0 ? rawLimit : 10000;
const offset = parseInt(req.query.offset) || 0;
const status = req.query.status;
const session_id = req.query.session_id;
const sources = parseSources(req);
// `session_ids=a,b,c` = every agent of those sessions in ONE call (the
// Dashboard's active trees), capped so the IN list stays small.
const sessionIds =
typeof req.query.session_ids === "string"
? [...new Set(req.query.session_ids.split(",").map((s) => s.trim()))]
.filter(Boolean)
.slice(0, 200)
: null;
let rows;
if (sessionIds) {
rows = sessionIds.length
? db
.prepare(
`SELECT * FROM agents WHERE session_id IN (${sessionIds.map(() => "?").join(",")})
ORDER BY started_at DESC LIMIT ?`
)
.all(...sessionIds, limit)
: [];
} else if (session_id) {
// A session belongs to exactly one source, so no extra source filter needed.
rows = stmts.listAgentsBySession.all(session_id);
} else if (sources || memberId(req) != null) {
// Data-scope active: build a dynamic query restricting agents to sessions
// from the chosen machines. `agents` carries only session_id → subquery.
// A server-mode member gets only their own sessions' agents the same way.
const clauses = [];
const params = [];
const src = sessionIdInSourcesClause(sources, "session_id");
if (src.clause) {
clauses.push(src.clause);
params.push(...src.params);
}
if (memberId(req) != null) {
const own = bySessionWhere(req);
clauses.push(own.where);
params.push(...own.params);
}
if (status) {
clauses.push("status = ?");
params.push(status);
}
rows = db
.prepare(
`SELECT * FROM agents WHERE ${clauses.join(" AND ")} ORDER BY started_at DESC LIMIT ? OFFSET ?`
)
.all(...params, limit, offset);
} else if (status) {
rows = stmts.listAgentsByStatus.all(status, limit, offset);
} else {
rows = stmts.listAgents.all(limit, offset);
}
// Attach each agent's OWN cost (from its metadata token buckets) so subagent
// cards can show their real cost instead of the session total.
res.json({ agents: attachAgentCosts(rows), limit, offset });
});
router.get("/:id", (req, res) => {
const agent = stmts.getAgent.get(req.params.id);
if (!agent) {
return res.status(404).json({ error: { code: "NOT_FOUND", message: "Agent not found" } });
}
res.json({ agent });
});
router.post("/", (req, res) => {
const { id, session_id, name, type, subagent_type, status, task, parent_agent_id, metadata } =
req.body;
if (!id || !session_id || !name) {
return res.status(400).json({
error: { code: "INVALID_INPUT", message: "id, session_id, and name are required" },
});
}
const existing = stmts.getAgent.get(id);
// Server mode: another user's agent is "not found", not handed back.
if (existing && !require("../lib/scope").canSeeAgent(req, id)) {
return res.status(404).json({ error: { code: "ENOTFOUND", message: "not found" } });
}
if (existing) {
return res.json({ agent: existing, created: false });
}
stmts.insertAgent.run(
id,
session_id,
name,
type || "main",
subagent_type || null,
status || "waiting",
task || null,
parent_agent_id || null,
metadata ? JSON.stringify(metadata) : null
);
const agent = stmts.getAgent.get(id);
broadcast("agent_created", agent);
res.status(201).json({ agent, created: true });
});
router.patch("/:id", (req, res) => {
const { name, status, task, current_tool, ended_at, metadata } = req.body;
const existing = stmts.getAgent.get(req.params.id);
if (!existing) {
return res.status(404).json({ error: { code: "NOT_FOUND", message: "Agent not found" } });
}
stmts.updateAgent.run(
name || null,
status || null,
task || null,
current_tool !== undefined ? current_tool : existing.current_tool,
ended_at || null,
metadata ? JSON.stringify(metadata) : null,
req.params.id
);
const agent = stmts.getAgent.get(req.params.id);
broadcast("agent_updated", agent);
res.json({ agent });
});
module.exports = router;