diff --git a/client/src/components/Sagi/__tests__/Sagi.test.tsx b/client/src/components/Sagi/__tests__/Sagi.test.tsx index 196aabb0..74cf6079 100644 --- a/client/src/components/Sagi/__tests__/Sagi.test.tsx +++ b/client/src/components/Sagi/__tests__/Sagi.test.tsx @@ -200,6 +200,20 @@ describe("Sagi widget", () => { expect(within(liveChip).getByText("2")).toBeInTheDocument(); }); + it("counts only the viewer's own sessions (another user's arrive marked mine: false)", () => { + renderSagi(); + act(() => { + eventBus.publish({ ...sessionMsg("mine", "active"), mine: true }); + eventBus.publish({ ...sessionMsg("theirs", "active"), mine: false }); + eventBus.publish({ ...sessionMsg("theirs-err", "error"), mine: false }); + }); + fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i })); + const panel = screen.getByRole("dialog", { name: /chat with sagi/i }); + const liveChip = within(panel).getByText("live").closest("span.rounded-full")! as HTMLElement; + expect(within(liveChip).getByText("1")).toBeInTheDocument(); + expect(screen.queryByText(/theirs/)).toBeNull(); + }); + it("respects the enabled preference", () => { localStorage.setItem("agent-dashboard-sagi-enabled", "false"); renderSagi(); diff --git a/client/src/components/Sagi/useSagiBrain.ts b/client/src/components/Sagi/useSagiBrain.ts index 1c0467f3..41e70982 100644 --- a/client/src/components/Sagi/useSagiBrain.ts +++ b/client/src/components/Sagi/useSagiBrain.ts @@ -7,7 +7,8 @@ * burst of the same event into one count), the pet's one-shot animation * "moment", and imperative controls (mute, clear alerts, pat, set thinking) * for the UI shell. It also greets once per browser session and makes an - * idle pet fidget now and then. + * idle pet fidget now and then. It only ever counts the viewer's OWN sessions + * (`mine=1` seed, and frames marked `mine: false` skipped). * @author Nguyễn Ngọc Trí Vĩ */ /* ============================================================================= @@ -278,7 +279,7 @@ export function useSagiBrain(): SagiBrain { }, GREET_DELAY_MS); }; api.sessions - .list({ status: "active", limit: 100 }) + .list({ status: "active", limit: 100, mine: true }) .then((res) => { if (cancelled) return; update((prev) => seedSessions(prev, res.sessions, Date.now())); @@ -294,6 +295,9 @@ export function useSagiBrain(): SagiBrain { // Subscribe to the live stream and connection status. useEffect(() => { const unsubMsg = eventBus.subscribe((msg: WSMessage) => { + // Each user's pet tells only of their own sessions: an admin/analyst + // gets every user's, marked `mine: false` when not theirs. + if (msg.mine === false) return; const prev = stateRef.current; const { state: next, pulse, subject } = reduceSagi(prev, msg, Date.now()); // Most frames only move lastActivityAt, which the moods read at a 1 s diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts index 2cfff12e..a02d74ff 100644 --- a/client/src/lib/api.ts +++ b/client/src/lib/api.ts @@ -1858,6 +1858,8 @@ export const api = { archived?: boolean; /** Pinned sessions first (by pin time), before the limit. */ pinnedFirst?: boolean; + /** Server mode: the caller's OWN sessions, even for an admin/analyst (`mine=1`). */ + mine?: boolean; /** Only active sessions waiting on the user (`awaiting=1`). */ awaiting?: boolean; /** ISO bounds on `started_at`. */ @@ -1878,6 +1880,7 @@ export const api = { if (params?.archived !== undefined) qs.set("archived", params.archived ? "1" : "0"); if (params?.pinnedFirst) qs.set("pinned_first", "1"); if (params?.awaiting) qs.set("awaiting", "1"); + if (params?.mine) qs.set("mine", "1"); if (params?.from) qs.set("from", params.from); if (params?.to) qs.set("to", params.to); if (params?.sort_by) qs.set("sort_by", params.sort_by); diff --git a/client/src/lib/types.ts b/client/src/lib/types.ts index 6c5a0cdf..175af0db 100644 --- a/client/src/lib/types.ts +++ b/client/src/lib/types.ts @@ -1971,6 +1971,10 @@ export interface WSMessage { /** ISO timestamp the server broadcast this message (not necessarily the * same instant the underlying event occurred). */ timestamp: string; + /** Server mode, an admin/analyst's session/agent/event frames only: whether + * the session is their OWN (they also get every other user's). Absent for a + * developer (who only ever gets their own) and in local mode. */ + mine?: boolean; } // ───── Session stats ───── diff --git a/docs/API.md b/docs/API.md index b86586d8..ccb4b0fb 100644 --- a/docs/API.md +++ b/docs/API.md @@ -2176,6 +2176,7 @@ Returns all sessions, ordered by most recent activity. | `offset` | integer | 0 | Pagination offset | | `status` | string | - | Filter by persisted status: `active`, `completed`, `error`, `abandoned`. The UI **Waiting** state is derived from the `awaiting_input_since` column and is not a `status` value — use `awaiting=1` | | `awaiting` | `1` | - | Only sessions blocked on the user: `awaiting_input_since IS NOT NULL AND status = 'active'` (the UI's **Waiting** filter) | +| `mine` | `1` | - | Server mode: only the caller's OWN sessions (workspaces they own, or their Linux account's), even for an admin or analyst. The pet seeds its count from it. No-op in local mode | | `from` / `to` | ISO 8601 | - | Inclusive bounds on `started_at`; an unparseable value is ignored. Like every filter here, applied before `limit`/`offset`, so `total` stays exact | | `cwd` | string | - | Exact working directory | | `tool` | string | - | One CLI's sessions: `claude`, `codex`, `antigravity`. Also accepted on `/api/stats`, `/api/analytics`, `/api/workflows` (and `/workflows/skill-usage`) and `/api/pricing/cost`, where a malformed id is `400 INVALID_INPUT` | @@ -3718,6 +3719,8 @@ stateDiagram-v2 Server broadcasts JSON messages to all connected clients: +Server mode: a developer gets only the frames of their own sessions and workspaces. An admin or analyst gets every user's, and each `session_created`, `session_updated`, `agent_created`, `agent_updated` and `new_event` frame they receive also carries `"mine": true|false`, which says whether the session is their own (the rule of `GET /api/sessions?mine=1`). The pet counts only frames with `mine` absent or true. Local mode never adds `mine`. + #### session.created Sent when a new session is created. diff --git a/server/__tests__/websocket-mine.test.js b/server/__tests__/websocket-mine.test.js new file mode 100644 index 00000000..5df9a10a --- /dev/null +++ b/server/__tests__/websocket-mine.test.js @@ -0,0 +1,102 @@ +/** + * @file websocket-mine.test.js + * @description Server mode broadcasts (server/websocket.js `broadcast`): an + * admin/analyst's session, agent and event frames say whether the session is + * their OWN (`mine`), a developer's and local mode's never carry it, and + * `GET /api/sessions?mine=1` (server/routes/sessions.js) lists only the + * caller's own sessions whatever their role — the pet counts only those. + * @author Nguyễn Ngọc Trí Vĩ + */ + +const { describe, it, before, after } = require("node:test"); +const assert = require("node:assert/strict"); +const path = require("node:path"); +const fs = require("node:fs"); +const os = require("node:os"); + +const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "ws-mine-test-")); +process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db"); + +const { db } = require("../db"); +const ws = require("../websocket"); + +const prevMode = process.env.AGENTHUB_MODE; +before(() => { + const lane = db.prepare("INSERT INTO lanes (cwd, tool, owner_id) VALUES (?, 'claude', ?)"); + lane.run("/w/boss", 99); // id 1, the admin's + lane.run("/w/dev", 2); // id 2, a developer's + const s = db.prepare( + "INSERT INTO sessions (id, name, status, cwd, lane_id) VALUES (?, ?, 'active', '/w', ?)" + ); + s.run("s-boss", "boss", 1); + s.run("s-dev", "dev", 2); +}); + +after(() => { + ws._setClientsForTest(null); + if (prevMode === undefined) delete process.env.AGENTHUB_MODE; + else process.env.AGENTHUB_MODE = prevMode; + fs.rmSync(TMP, { recursive: true, force: true }); +}); + +function client(user) { + const got = []; + return { readyState: 1, user, got, send: (m) => got.push(JSON.parse(m)) }; +} + +describe("broadcast `mine`", () => { + it("marks an admin's frames by whose session it is; a developer gets only theirs, unmarked", () => { + process.env.AGENTHUB_MODE = "server"; + const boss = client({ id: 99, username: "boss", role: "admin" }); + const dev = client({ id: 2, username: "dev", role: "developer" }); + ws._setClientsForTest(new Set([boss, dev])); + ws.broadcast("session_updated", { id: "s-boss", status: "active" }); + ws.broadcast("new_event", { session_id: "s-dev", event_type: "PostToolUse" }); + ws.broadcast("lane_update", { lane: { id: 2 } }); + assert.deepEqual( + boss.got.map((m) => [m.type, m.mine]), + [ + ["session_updated", true], + ["new_event", false], + ["lane_update", undefined], + ] + ); + assert.deepEqual( + dev.got.map((m) => [m.type, m.mine]), + [ + ["new_event", undefined], + ["lane_update", undefined], + ] + ); + }); + + it("adds nothing in local mode", () => { + delete process.env.AGENTHUB_MODE; + const me = client(null); + ws._setClientsForTest(new Set([me])); + ws.broadcast("session_updated", { id: "s-dev" }); + assert.equal(me.got[0].mine, undefined); + }); +}); + +describe("GET /api/sessions?mine=1", () => { + it("lists only the caller's own sessions, an admin's too", async () => { + const express = require("express"); + const app = express(); + app.use((req, _res, next) => { + req.user = { id: 99, username: "boss", role: "admin" }; + next(); + }); + app.use("/api/sessions", require("../routes/sessions")); + const srv = app.listen(0); + try { + const base = `http://127.0.0.1:${srv.address().port}/api/sessions`; + const ids = async (qs) => + (await (await fetch(base + qs)).json()).sessions.map((s) => s.id).sort(); + assert.deepEqual(await ids("?mine=1"), ["s-boss"]); + assert.deepEqual(await ids(""), ["s-boss", "s-dev"]); + } finally { + srv.close(); + } + }); +}); diff --git a/server/openapi-extra/override-sessions-agents.js b/server/openapi-extra/override-sessions-agents.js index 92b44207..04df4d54 100644 --- a/server/openapi-extra/override-sessions-agents.js +++ b/server/openapi-extra/override-sessions-agents.js @@ -154,6 +154,14 @@ const paths = { "`1` = only sessions blocked on the user (`awaiting_input_since IS NOT NULL AND status = 'active'`), the UI's Waiting filter. Applied before `limit`/`offset`, so `total` stays exact.", example: "1", }, + { + name: "mine", + in: "query", + schema: { type: "string", enum: ["1"] }, + description: + "Server mode: `1` = only the caller's OWN sessions, even for an admin or analyst (the pet's count). No-op in local mode.", + example: "1", + }, { name: "from", in: "query", diff --git a/server/routes/sessions.js b/server/routes/sessions.js index 9c053682..0141f781 100644 --- a/server/routes/sessions.js +++ b/server/routes/sessions.js @@ -160,6 +160,13 @@ router.get("/", (req, res) => { ); params.push(Number(lane), Number(lane)); } + // `mine=1` = the requesting user's OWN sessions whatever their role (the + // pet's count): an admin/analyst otherwise sees every user's. "1" locally. + if (req.query.mine === "1") { + const o = require("../lib/scope").ownSessionWhere(req, "s.id", "s.lane_id"); + where.push(o.where); + params.push(...o.params); + } // `awaiting=1` = only sessions blocked on the user (an active session with // `awaiting_input_since`), the UI's "Waiting" filter, done before the limit. if (req.query.awaiting === "1") { diff --git a/server/websocket.js b/server/websocket.js index 44a4c312..44faeae1 100644 --- a/server/websocket.js +++ b/server/websocket.js @@ -1,5 +1,5 @@ /** - * @file WebSocket functionalities for real-time communication with clients, including connection management, heartbeat for detecting dead connections, and broadcasting messages to all connected clients. + * @file WebSocket functionalities for real-time communication with clients, including connection management, heartbeat for detecting dead connections, and broadcasting messages to all connected clients (server mode: each user only what they may see, and an admin/analyst's session frames marked `mine` when the session is their own). * @author Nguyễn Ngọc Trí Vĩ */ @@ -9,6 +9,14 @@ const { isWebSocketLoggedIn, isServerMode, userFromRequest } = require("./lib/au const { broadcastAudience, exactAudience, audienceIncludes, canSeeRun } = require("./lib/scope"); let wss = null; +// Session-scoped broadcasts an admin/analyst gets with `mine` (see broadcast). +const MINE_TYPES = new Set([ + "session_created", + "session_updated", + "agent_created", + "agent_updated", + "new_event", +]); // How often an open terminal's login is re-checked in server mode. const PTY_RECHECK_MS = Number(process.env.AGENTHUB_PTY_RECHECK_MS) || 10000; @@ -230,7 +238,8 @@ function initPtyWebSocket(server) { function broadcast(type, data) { if (!wss) return; - const message = JSON.stringify({ type, data, timestamp: new Date().toISOString() }); + const timestamp = new Date().toISOString(); + const message = JSON.stringify({ type, data, timestamp }); // Server mode: worked out once, and only when a member is connected. let audience; // A message for exactly these users (a member's own config change): no @@ -255,6 +264,24 @@ function broadcast(type, data) { return audienceIncludes(audience, client.user); }; let stripped; + // Server mode: an admin/analyst gets every user's session news, so a + // session-scoped frame tells them whether it is their OWN (`mine`) — the + // pet counts only those. Worked out once per broadcast, on first need. + const owned = MINE_TYPES.has(type) && isServerMode(); + let mineMsg; + let otherMsg; + const forStaff = (client) => { + if (audience === undefined) { + try { + audience = broadcastAudience(type, data); + } catch { + audience = { staffOnly: true }; + } + } + const mine = Boolean(audience && audience.owners && audience.owners.has(client.user.id)); + if (mine) return (mineMsg ??= JSON.stringify({ type, data, timestamp, mine: true })); + return (otherMsg ??= JSON.stringify({ type, data, timestamp, mine: false })); + }; wss.clients.forEach((client) => { if (client.readyState === 1 && reaches(client)) { try { @@ -264,6 +291,10 @@ function broadcast(type, data) { client.send(stripped); return; } + if (owned && !only && client.user && !member) { + client.send(forStaff(client)); + return; + } client.send(message); } catch { // Client closed between readyState check and send — safe to ignore @@ -331,7 +362,13 @@ function closeWebSocket() { } } +/** Tests only: broadcast to these fake clients (`{readyState, user, send}`). */ +function _setClientsForTest(clients) { + wss = clients ? { clients } : null; +} + module.exports = { + _setClientsForTest, initWebSocket, initPtyWebSocket, broadcast,