From 18e574a76519efd4c9d4d499eba67f3286ef782e Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Fri, 6 Nov 2026 11:32:25 +0700 Subject: [PATCH] feat(server-mode): pane presence and typing audit (sharing phase 4) A pane's tab shows the other people who have it open (pane-presence.js, in memory, fed by the /ws-pty attach; GET /api/lanes/:id/presence and the pane_presence broadcast to the owner and full collaborators) and says "Billed to " for a pane a collaborator opened (startedBy on the run). The first real keystroke of each attach into a shared workspace's pane by anyone but its owner is audited as `type into ` (mouse, focus and terminal replies to tmux do not count). The pty socket now pings, so a vanished browser is ended at the next re-check and leaves the presence list. --- CLAUDE.md | 2 +- client/src/components/run/ConsoleTabs.tsx | 45 +++++- .../__tests__/ConsoleTabs.presence.test.tsx | 79 +++++++++++ client/src/hooks/usePanePresence.ts | 54 ++++++++ client/src/i18n/locales/en/lanes.json | 2 + client/src/i18n/locales/vi/lanes.json | 2 + client/src/lib/api.ts | 8 ++ client/src/lib/types.ts | 2 + docs/API.md | 11 ++ docs/SERVER-MODE.md | 11 ++ docs/plans/workspace-sharing/README.md | 2 +- openapi.yaml | 23 ++++ server/__tests__/pane-presence.test.js | 128 ++++++++++++++++++ server/lib/dashboard-runs.js | 2 +- server/lib/pane-presence.js | 111 +++++++++++++++ server/lib/pty-attach.js | 20 ++- server/lib/pty-run.js | 16 +++ server/lib/scope.js | 1 + server/openapi-extra/lanes-more.js | 11 ++ server/routes/lanes.js | 10 ++ server/websocket.js | 36 ++++- 21 files changed, 568 insertions(+), 8 deletions(-) create mode 100644 client/src/components/run/__tests__/ConsoleTabs.presence.test.tsx create mode 100644 client/src/hooks/usePanePresence.ts create mode 100644 server/__tests__/pane-presence.test.js create mode 100644 server/lib/pane-presence.js diff --git a/CLAUDE.md b/CLAUDE.md index e1920d98..dffcce6e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # Claude Code Working Guide -_Last synced: v2.8.77+ (2026-11-06) — sharing: collaborators pay for their own runs; one session can be shared._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session. +_Last synced: v2.8.77+ (2026-11-06) — sharing: payer billing, one-session shares, pane presence + typing audit._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session. ## Project mission - Maintain a reliable local-first dashboard for Claude Code session monitoring. diff --git a/client/src/components/run/ConsoleTabs.tsx b/client/src/components/run/ConsoleTabs.tsx index 02c941ee..b3912ba9 100644 --- a/client/src/components/run/ConsoleTabs.tsx +++ b/client/src/components/run/ConsoleTabs.tsx @@ -85,6 +85,8 @@ import { slotOfKey, } from "../../lib/paneLayout"; import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu"; +import { useAuth } from "../AuthGate"; +import { usePanePresence } from "../../hooks/usePanePresence"; type AiToolsState = ReturnType; @@ -234,6 +236,19 @@ export function ConsoleTabs({ const tabRefs = useRef(new Map()); const menuRef = useRef(null); const tabCtx = useContextMenu(); + // Server mode: who else has these panes open, and whose money a pane is. + const presence = usePanePresence(lane.id); + const { state: auth } = useAuth(); + const meId = auth.mode === "server" ? auth.user?.id : undefined; + const othersIn = (tab: Tab) => { + const seen = new Map(); + for (const r of tab.runs) { + for (const u of presence[r.id] ?? []) if (u.id !== meId) seen.set(u.id, u); + } + return [...seen.values()]; + }; + const billedTo = (tab: Tab) => + [...new Set(tab.runs.map((r) => r.startedBy?.name).filter(Boolean))] as string[]; // A group whose runs the list has not caught up with yet is not drawn. const tabs = useMemo( @@ -532,6 +547,7 @@ export function ConsoleTabs({ {tabs.map((tab, i) => { const active = !settings?.active && tab.index === groups.active; const single = tab.runs.length === 1 ? tab.focus : null; + const others = othersIn(tab); const state = tabState(tab); if (single && renaming === single.id) { return ( @@ -606,7 +622,7 @@ export function ConsoleTabs({ // Ctrl/Cmd+click brings a one-pane tab into the tab on screen. onClick={(e) => onOpen(tab.focus.id, !!single && (e.ctrlKey || e.metaKey))} onDoubleClick={() => single && startRename(single)} - title={ + title={[ single ? [ toolLabel(single), @@ -617,8 +633,14 @@ export function ConsoleTabs({ ] .filter(Boolean) .join("\n") - : tab.runs.map((r) => `${toolLabel(r)} — ${label(r)}`).join("\n") - } + : tab.runs.map((r) => `${toolLabel(r)} — ${label(r)}`).join("\n"), + ...billedTo(tab).map((name) => t("tabs.billedTo", { name })), + others.length + ? t("tabs.here", { names: others.map((u) => u.name).join(", ") }) + : null, + ] + .filter(Boolean) + .join("\n")} className="flex h-full min-w-0 items-center gap-2 pl-3 pr-1.5 text-sm" > @@ -627,6 +649,23 @@ export function ConsoleTabs({ ))} {tabLabel(tab)} + {others.length > 0 && ( + u.name).join(", ") })} + > + {others.slice(0, 3).map(({ id, name }) => ( + + {name.slice(0, 1)} + + ))} + + )} {state && } {tab.runs.some(canSleep) && ( diff --git a/client/src/components/run/__tests__/ConsoleTabs.presence.test.tsx b/client/src/components/run/__tests__/ConsoleTabs.presence.test.tsx new file mode 100644 index 00000000..0106387e --- /dev/null +++ b/client/src/components/run/__tests__/ConsoleTabs.presence.test.tsx @@ -0,0 +1,79 @@ +/** + * @file ConsoleTabs.presence.test.tsx + * @description Workspace sharing phase 4 on the tab bar: a tab shows who ELSE + * has its panes open (never the viewer), and its tooltip says whose money a + * pane a collaborator opened is. + * @author Nguyễn Ngọc Trí Vĩ + */ + +import { describe, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import "../../../i18n"; +import type { RunHandle } from "../../../lib/api"; +import type { Lane } from "../../../lib/types"; +import { pane, type PaneGroups } from "../../../lib/paneLayout"; +import { ConsoleTabs } from "../ConsoleTabs"; + +vi.mock("../../../lib/api", () => ({ + api: { + aiTools: { + list: vi.fn().mockResolvedValue({ default: "claude", tools: [] }), + models: vi.fn().mockResolvedValue({ flag: true, models: [], defaultModel: null }), + }, + }, +})); +vi.mock("../../AuthGate", () => ({ + useAuth: () => ({ state: { mode: "server", user: { id: 1, username: "alice" } } }), + usePermissions: () => ({ local: false, isAdmin: false }), +})); +vi.mock("../../../hooks/usePanePresence", () => ({ + usePanePresence: () => ({ + "agenthub-lane-1": [ + { id: 1, name: "Alice" }, + { id: 2, name: "Bob" }, + ], + }), +})); + +const LANE = { id: 1, tool: "claude", cwd: "/w", title: "w" } as unknown as Lane; +const RUN: RunHandle = { + id: "agenthub-lane-1", + laneId: 1, + slot: 0, + status: "running", + cwd: "/w", + model: null, + permissionMode: null, + effort: null, + resumeSessionId: null, + sessionId: null, + startedAt: null, + promptPreview: null, + startedBy: { id: 2, name: "Bob" }, + tool: "claude", +}; +const GROUPS: PaneGroups = { groups: [{ root: pane(0), focus: 0 }], active: 0 }; + +describe("ConsoleTabs presence", () => { + it("shows who else is in a pane, and whose money it is", () => { + render( + + + + ); + const people = screen.getAllByTestId(/^console-tab-people-/)[0]!; + expect(people.textContent).toBe("B"); + expect(people.getAttribute("aria-label")).toBe("Also here: Bob"); + const tab = screen.getAllByRole("tab")[0]!; + expect(tab.getAttribute("title")).toContain("Billed to Bob"); + expect(tab.getAttribute("title")).not.toContain("Alice"); + }); +}); diff --git a/client/src/hooks/usePanePresence.ts b/client/src/hooks/usePanePresence.ts new file mode 100644 index 00000000..c937d8eb --- /dev/null +++ b/client/src/hooks/usePanePresence.ts @@ -0,0 +1,54 @@ +/** + * @file usePanePresence.ts + * @description Who has each of a workspace's panes open (server mode, workspace + * sharing phase 4): `GET /api/lanes/:id/presence` once, then every + * `pane_presence` frame for that workspace, and a re-read on a WebSocket + * reconnect (frames missed while away are never replayed). Local mode has + * one user and reads nothing. Returns `{runId: [{id, name}]}`. + * @author Nguyễn Ngọc Trí Vĩ + */ + +import { useEffect, useState } from "react"; +import { api } from "../lib/api"; +import { eventBus } from "../lib/eventBus"; +import { usePermissions } from "../components/AuthGate"; + +export type PanePeople = Record; + +export function usePanePresence(laneId: number | null | undefined): PanePeople { + const { local } = usePermissions(); + const [panes, setPanes] = useState({}); + useEffect(() => { + setPanes({}); + if (local || laneId == null) return; + let alive = true; + const load = () => + Promise.resolve() + .then(() => api.lanes.presence(laneId)) + .then((r) => alive && setPanes(r.panes)) + .catch(() => { + /* presence is a nicety: no list, no chips */ + }); + void load(); + const offMsg = eventBus.subscribe((msg) => { + if (msg.type !== "pane_presence") return; + const d = msg.data as { laneId?: number; runId?: string; users?: PanePeople[string] }; + if (d.laneId !== laneId || !d.runId) return; + setPanes((prev) => { + const next = { ...prev }; + if (d.users?.length) next[d.runId!] = d.users; + else delete next[d.runId!]; + return next; + }); + }); + const offConn = eventBus.onConnection((up) => { + if (up) void load(); + }); + return () => { + alive = false; + offMsg(); + offConn(); + }; + }, [laneId, local]); + return panes; +} diff --git a/client/src/i18n/locales/en/lanes.json b/client/src/i18n/locales/en/lanes.json index 2afda2b1..d5140cdf 100644 --- a/client/src/i18n/locales/en/lanes.json +++ b/client/src/i18n/locales/en/lanes.json @@ -409,6 +409,8 @@ "share.sessionWhat": "People you share it with see this session and the workspace's changes, join its open tab, type into it, send it messages (on their own account, billed to them) and commit — nothing else of the workspace. Only you share it. On this server they work as YOUR account inside its terminal, so they can still reach the rest of your home from there.", "sessionMenu.share": "Share…", "shared.sessionOnly": "Only a session of this workspace is shared with you: open it from the list to work in it.", + "tabs.billedTo": "Billed to {{name}}", + "tabs.here": "Also here: {{names}}", "shared.badge": "Shared with you", "panel.changes": "Changes", "panel.analyst": "Analyst", diff --git a/client/src/i18n/locales/vi/lanes.json b/client/src/i18n/locales/vi/lanes.json index 3743daab..9a55527c 100644 --- a/client/src/i18n/locales/vi/lanes.json +++ b/client/src/i18n/locales/vi/lanes.json @@ -409,6 +409,8 @@ "share.sessionWhat": "Người được chia sẻ thấy session này và các thay đổi của workspace, vào tab đang mở, gõ, gửi tin nhắn cho nó (bằng tài khoản của họ, tính tiền cho họ) và commit — không gì khác của workspace. Chỉ bạn chia sẻ được. Trên server này họ làm việc bằng TÀI KHOẢN CỦA BẠN trong terminal của nó, nên từ đó họ vẫn với tới được phần còn lại trong home của bạn.", "sessionMenu.share": "Chia sẻ…", "shared.sessionOnly": "Chỉ một session của workspace này được chia sẻ với bạn: mở nó từ danh sách để làm việc.", + "tabs.billedTo": "Tính tiền cho {{name}}", + "tabs.here": "Cũng đang mở: {{names}}", "shared.badge": "Được chia sẻ với bạn", "panel.changes": "Thay đổi", "panel.analyst": "Analyst", diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts index cf288743..3d8954fc 100644 --- a/client/src/lib/api.ts +++ b/client/src/lib/api.ts @@ -5113,6 +5113,11 @@ export const api = { method: "PUT", body: JSON.stringify({ userIds }), }), + /** GET /api/lanes/:id/presence — who has which pane open (server mode). */ + presence: (id: number) => + request<{ panes: Record }>( + `/workspaces/${id}/presence` + ), /** GET /api/lanes/:id/sessions/:sid/shares — the users one session is shared with. */ sessionShares: (id: number, sid: string) => request<{ shares: LaneShare[]; canManage: boolean }>( @@ -6525,6 +6530,9 @@ export interface RunHandle { startedAt: string | null; /** Initial prompt preview (first 500 chars), null when not provided. */ promptPreview: string | null; + /** Server mode: a user the workspace is shared with opened it — they pay + * for it. Null/absent = the workspace's owner. */ + startedBy?: { id: number; name: string } | null; tool: LaneTool; } diff --git a/client/src/lib/types.ts b/client/src/lib/types.ts index e732b00e..783c658c 100644 --- a/client/src/lib/types.ts +++ b/client/src/lib/types.ts @@ -1944,6 +1944,8 @@ export interface WSMessage { | "lane_update" /** A workspace was shared with / taken back from the users named (`userIds`). */ | "lane_access" + /** Who has a pane open changed: `{laneId, runId, users}` (pane-presence.js). */ + | "pane_presence" /** One output line from a lane's profile hook, while it runs. */ | "lane_hook_output" /** A lane's stack finished coming up (or failed to), with fresh facts. */ diff --git a/docs/API.md b/docs/API.md index 382c0af6..1e5b0b2e 100644 --- a/docs/API.md +++ b/docs/API.md @@ -2126,6 +2126,17 @@ answers only that workspace's reads, the workspace list (filtered to it), its stage / up / down / hook / feature / mcp-sync writes and `/api/health` (`403 EPANESCOPE` — never a start or resume). +```http +GET /api/lanes/:id/presence → { panes: { "": [{id, name}] } } +``` + +Who has which pane open (server mode; local mode `{panes: {}}`), in memory; +changes come as the WebSocket message `pane_presence` `{laneId, runId, users}` +to the workspace's owner and full collaborators. A run handle +(`GET /api/run`) carries `startedBy: {id, name}` when a collaborator opened +it — they pay for it. The first keystroke of a terminal attach into a shared +workspace's pane by anyone but its owner is audited (`type into `). + ```http GET /api/lanes/:id/sessions/:sid/shares → { shares, canManage } PUT /api/lanes/:id/sessions/:sid/shares {"userIds": [3]} → { shares } diff --git a/docs/SERVER-MODE.md b/docs/SERVER-MODE.md index 0f9f6bea..f9f419c5 100644 --- a/docs/SERVER-MODE.md +++ b/docs/SERVER-MODE.md @@ -311,6 +311,17 @@ resume or any other call that could bill them. The CLI's workspace commands work from their pane; the MCP server and the statusline's spend line do not. A pane the owner or an admin opens still acts as the owner. +**Who is here, and who pays.** A pane's tab shows the other people who have +it open (`pane_presence`, `GET /api/lanes/:id/presence`, in memory — never +the viewer themself), and its tooltip says "Billed to " for a pane a +collaborator opened (`startedBy` on the run). The first keystroke of each +terminal attach into a SHARED workspace's pane (whole or one session of it) +by anyone but its owner is written to the audit log as `type into ` — +who typed where, never what (mouse, focus and the terminal's own answers to +tmux do not count); the Chat composer types through the same attach, so its +prompts are covered too. A terminal that stops answering pings (a sleeping +laptop) is ended at the next re-check, and its presence with it. + #### Sharing one session The owner (or an admin) can also share ONE session: right-click it in the diff --git a/docs/plans/workspace-sharing/README.md b/docs/plans/workspace-sharing/README.md index 1f248131..672efacd 100644 --- a/docs/plans/workspace-sharing/README.md +++ b/docs/plans/workspace-sharing/README.md @@ -1,6 +1,6 @@ # Workspace & session sharing — plan -Status: **phases 1–3 built 2026-11-06** (workspace sharing; collaborators run on their own account, billed per payer by hour — `session_payers`; one session can be shared — `session_shares`; R1 decided as option **(a)** — accept and warn hard). Phase 4 not built. +Status: **phases 1–4 built 2026-11-06** (workspace sharing; collaborators run on their own account, billed per payer by hour — `session_payers`; one session can be shared — `session_shares`; pane presence + typing audit; R1 decided as option **(a)** — accept and warn hard). An independent review of phase 1 found and got fixed: a collaborator's Terminal tab acted as the owner through its pane token (now `dashboard_runs.started_by`), `POST /api/run {shell:true}` bypassed `ECOLLABSTART`, a disabled collaborator diff --git a/openapi.yaml b/openapi.yaml index dfe692c5..14e3f93b 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -20478,6 +20478,29 @@ paths: description: ENOTOWNER. '404': description: ENOLANE — workspace not found. + /api/lanes/{id}/presence: + get: + tags: + - Lanes + summary: Who has which of a workspace's panes open + description: 'Server mode (local mode: `{panes: {}}`). `{panes: {: [{id, name}]}}` — the users with a terminal attached to each pane, from memory; changes arrive as the WebSocket message `pane_presence` `{laneId, runId, users}` (the workspace''s owner and the users it is shared with).' + operationId: getLanePresence + parameters: + - name: id + in: path + required: true + schema: + type: integer + responses: + '200': + description: '`{panes}`.' + content: + application/json: + schema: + type: object + additionalProperties: true + '404': + description: ENOLANE — workspace not found. /api/lanes/{id}/sessions/{sid}/shares: get: tags: diff --git a/server/__tests__/pane-presence.test.js b/server/__tests__/pane-presence.test.js new file mode 100644 index 00000000..aa5a18ab --- /dev/null +++ b/server/__tests__/pane-presence.test.js @@ -0,0 +1,128 @@ +/** + * @file pane-presence.test.js + * @description Who has which pane open (server/lib/pane-presence.js, sharing + * phase 4): a user counted once however many attaches they hold, gone with + * their last one, listed per workspace; the first keystroke into a SHARED + * workspace's pane is audited (who and where, never what) and an unshared + * one's is not; `pane_presence` reaches the workspace's owner and the users + * it is shared with, nobody else. + * @author Nguyễn Ngọc Trí Vĩ + */ + +const { describe, it, before, after, beforeEach } = 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(), "pane-presence-test-")); +process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db"); + +const { db } = require("../db"); +const presence = require("../lib/pane-presence"); +const scope = require("../lib/scope"); + +let alice; +let bob; +let carol; +let lane; +let lonely; + +before(() => { + const user = (name) => + Number( + db + .prepare("INSERT INTO users (username, password_hash, role) VALUES (?, 'x', 'developer')") + .run(name).lastInsertRowid + ); + alice = user("alice"); + bob = user("bob"); + carol = user("carol"); + const mk = () => + Number( + db + .prepare("INSERT INTO lanes (cwd, tool, owner_id) VALUES (?, 'claude', ?)") + .run(`/w/${Math.random()}`, alice).lastInsertRowid + ); + lane = mk(); + lonely = mk(); + db.prepare("INSERT INTO lane_shares (lane_id, user_id) VALUES (?, ?)").run(lane, bob); +}); + +after(() => fs.rmSync(TMP, { recursive: true, force: true })); +beforeEach(() => presence.__reset()); + +describe("pane presence", () => { + it("counts a user once per pane, until their last attach closes", () => { + const run = `agenthub-lane-${lane}-1`; + presence.join(run, { id: bob, username: "bob", displayName: "Bob B" }); + presence.join(run, { id: bob, username: "bob" }); + presence.join(run, { id: alice, username: "alice" }); + assert.deepEqual(presence.forLane(lane)[run], [ + { id: bob, name: "Bob B" }, + { id: alice, name: "alice" }, + ]); + presence.leave(run, { id: bob }); + assert.equal(presence.forLane(lane)[run].length, 2, "one of bob's attaches is still open"); + presence.leave(run, { id: bob }); + presence.leave(run, { id: alice }); + assert.deepEqual(presence.forLane(lane), {}); + presence.join("not-a-run", { id: bob }); + assert.deepEqual(presence.users("not-a-run"), []); + }); + + it("audits the first keystroke only in a shared workspace", () => { + const count = () => + db.prepare("SELECT COUNT(*) AS n FROM audit_log WHERE path LIKE 'type into %'").get().n; + presence.auditInput(`agenthub-lane-${lonely}`, { id: alice }); + assert.equal(count(), 0); + presence.auditInput(`agenthub-lane-${lane}-2`, { id: bob }); + const row = db + .prepare( + "SELECT user_id, owner_id, method, path FROM audit_log WHERE path LIKE 'type into %'" + ) + .get(); + assert.deepEqual(row, { + user_id: bob, + owner_id: alice, + method: "PTY", + path: `type into agenthub-lane-${lane}-2`, + }); + }); + + it("is news for the workspace's owner and collaborators only", () => { + const aud = scope.broadcastAudience("pane_presence", { laneId: lane, runId: "x", users: [] }); + const dev = (id) => ({ id, role: "developer" }); + assert.equal(scope.audienceIncludes(aud, dev(alice)), true); + assert.equal(scope.audienceIncludes(aud, dev(bob)), true); + assert.equal(scope.audienceIncludes(aud, dev(carol)), false); + }); +}); + +describe("what counts as typing", () => { + const { isTyping } = require("../lib/pty-attach"); + it("ignores mouse, focus and the terminal answering tmux", () => { + for (const s of [ + "\x1b[<0;10;5M", + "\x1b[I", + "\x1b[>0;276;0c", + "\x1b[?1;2c", + "\x1b[12;40R", + "\x1b[0n", + "\x1b[?2004;1$y", + "\x1b]11;rgb:1e1e/1e1e/1e1e\x1b\\", + "\x1b]10;rgb:ffff/ffff/ffff\x07", + "\x1bP>|xterm.js(6.0.0)\x1b\\", + ]) { + assert.equal(isTyping(s), false, JSON.stringify(s)); + } + assert.equal(isTyping("ls\r"), true); + assert.equal(isTyping("\x1b[A"), true, "an arrow key is typing"); + }); + + it("never audits the owner's own typing", () => { + const before = db.prepare("SELECT COUNT(*) AS n FROM audit_log").get().n; + presence.auditInput(`agenthub-lane-${lane}`, { id: alice }); + assert.equal(db.prepare("SELECT COUNT(*) AS n FROM audit_log").get().n, before); + }); +}); diff --git a/server/lib/dashboard-runs.js b/server/lib/dashboard-runs.js index fdc22c3d..4ec557ac 100644 --- a/server/lib/dashboard-runs.js +++ b/server/lib/dashboard-runs.js @@ -53,7 +53,7 @@ const updateStmt = db.prepare(` const RUN_COLUMNS = `id, session_id, mode, cwd, model, permission_mode, effort, resume_session_id, prompt_preview, status, exit_code, - started_at, ended_at, lane_id, tool, worktree, proxy, account`; + started_at, ended_at, lane_id, tool, worktree, proxy, account, started_by`; const listStmt = db.prepare(` SELECT ${RUN_COLUMNS} diff --git a/server/lib/pane-presence.js b/server/lib/pane-presence.js new file mode 100644 index 00000000..111a11e3 --- /dev/null +++ b/server/lib/pane-presence.js @@ -0,0 +1,111 @@ +/** + * @file pane-presence.js + * @description Who has which pane open right now (server mode, workspace + * sharing phase 4): every terminal attach (`/ws-pty/:runId`, websocket.js) + * adds its logged-in user to its pane, and its close takes them off. In + * memory only — a restart drops every attach anyway. A change is broadcast as + * `pane_presence` `{laneId, runId, users}` to the workspace's owner and the + * users it is shared with; `GET /api/lanes/:id/presence` gives the current + * state. The first keystroke of an attach into a SHARED workspace's pane by + * anyone but its owner is written to `audit_log` (`type into `) — who + * typed, never what. + * @author Nguyễn Ngọc Trí Vĩ + */ + +/** runId → Map(userId → {id, name, n}) — `n` attaches of that user. */ +const panes = new Map(); + +const laneOf = (runId) => { + const m = /^agenthub-lane-(\d+)(?:-\d+)?$/.exec(String(runId || "")); + return m ? Number(m[1]) : null; +}; + +const nameOf = (user) => user.displayName || user.display_name || user.username; + +function users(runId) { + return [...(panes.get(runId)?.values() || [])].map(({ id, name }) => ({ id, name })); +} + +function announce(runId) { + try { + require("../websocket").broadcast("pane_presence", { + laneId: laneOf(runId), + runId, + users: users(runId), + }); + } catch { + /* no websocket server (tests) */ + } +} + +/** `user` opened pane `runId`. */ +function join(runId, user) { + if (!user || laneOf(runId) == null) return; + if (!panes.has(runId)) panes.set(runId, new Map()); + const here = panes.get(runId); + const me = here.get(user.id); + if (me) { + me.n += 1; + return; + } + here.set(user.id, { id: user.id, name: nameOf(user), n: 1 }); + announce(runId); +} + +/** `user` closed one attach of pane `runId`. */ +function leave(runId, user) { + const here = user && panes.get(runId); + const me = here?.get(user.id); + if (!me) return; + me.n -= 1; + if (me.n > 0) return; + here.delete(user.id); + if (!here.size) panes.delete(runId); + announce(runId); +} + +/** `{runId: [{id, name}]}` for workspace `laneId`'s panes. */ +function forLane(laneId) { + const out = {}; + for (const runId of panes.keys()) { + if (laneOf(runId) === Number(laneId)) out[runId] = users(runId); + } + return out; +} + +/** Whether workspace `laneId` is shared with anyone, whole or a session of it. */ +function isShared(laneId) { + const { db } = require("../db"); + return Boolean( + db + .prepare( + `SELECT 1 FROM lane_shares WHERE lane_id = ? + UNION ALL SELECT 1 FROM session_shares + WHERE session_id IN (SELECT id FROM sessions WHERE lane_id = ?) LIMIT 1` + ) + .get(laneId, laneId) + ); +} + +/** + * The first keystroke of an attach: in a shared workspace, write who typed + * into which pane (`audit_log`, owner = the workspace's owner). Best effort. + */ +function auditInput(runId, user) { + const laneId = laneOf(runId); + if (!user || laneId == null) return; + try { + if (!isShared(laneId)) return; + const { db } = require("../db"); + const owner = db.prepare("SELECT owner_id FROM lanes WHERE id = ?").get(laneId)?.owner_id; + if (owner === user.id) return; // the owner in their own workspace + require("./audit").recordAccountChange(user.id, owner ?? null, "PTY", `type into ${runId}`); + } catch (err) { + console.warn("[presence] could not audit:", err.message); + } +} + +/** Test seam. */ +const __reset = () => panes.clear(); + +module.exports = { join, leave, forLane, auditInput, users, __reset }; diff --git a/server/lib/pty-attach.js b/server/lib/pty-attach.js index 357f2e75..be1b0589 100644 --- a/server/lib/pty-attach.js +++ b/server/lib/pty-attach.js @@ -82,6 +82,17 @@ const PASTE_HOLD_MS = 700; // keystroke first leaves copy-mode (`tmux.leaveCopyMode`), then is written. const NOT_TYPING_RE = /\x1b\[<\d+;\d+;\d+[Mm]|\x1b\[M[\s\S]{3}|\x1b\[[IO]/g; +// What the browser's terminal sends back on its own when tmux asks it +// something on attach (device attributes, cursor/status reports, mode +// reports, colour queries): no one typed it. +const TERMINAL_REPLY_RE = + /\x1b\[[?>=]?[\d;]*[cRn]|\x1b\[\?[\d;]*\$y|\x1bP[\s\S]*?\x1b\\|\x1b\][\s\S]*?(?:\x07|\x1b\\)/g; + +/** Whether `text` from a browser is someone typing (not a mouse, focus or terminal reply). */ +function isTyping(text) { + return text.replace(NOT_TYPING_RE, "").replace(TERMINAL_REPLY_RE, "") !== ""; +} + // runId -> { driver, viewers: Set<{ ws, pty, cols, rows }>, scrolled } // (`scrolled`: a mouse report reached the pane since the last keystroke — per // pane, since one browser may scroll and another type) @@ -333,4 +344,11 @@ __setSpawnImpl((...args) => require("@lydell/node-pty").spawn(...args)); const __resetGroups = () => groups.clear(); -module.exports = { attach, attachBinary, validateRunId, __setSpawnImpl, __resetGroups }; +module.exports = { + attach, + attachBinary, + validateRunId, + isTyping, + __setSpawnImpl, + __resetGroups, +}; diff --git a/server/lib/pty-run.js b/server/lib/pty-run.js index f8ee3709..8d0270c0 100644 --- a/server/lib/pty-run.js +++ b/server/lib/pty-run.js @@ -934,9 +934,25 @@ function publicRun(id, live = tmux.hasSession(id)) { worktree: !!row?.worktree, startedAt: row?.started_at || null, promptPreview: row?.prompt_preview || null, + // Server mode: a pane a user the workspace is shared with opened — whose + // money it is (session-payers.js). Null = the workspace's owner. + startedBy: row?.started_by != null ? starterOf(row.started_by) : null, }; } +function starterOf(userId) { + try { + const u = require("../db") + .db.prepare( + "SELECT id, COALESCE(NULLIF(display_name, ''), username) AS name FROM users WHERE id = ?" + ) + .get(userId); + return u ? { id: u.id, name: u.name } : null; + } catch { + return null; + } +} + function getRun(id) { if (laneIdFromRunId(id) === null) return null; return publicRun(id); diff --git a/server/lib/scope.js b/server/lib/scope.js index 1fc26ad4..79bba7f9 100644 --- a/server/lib/scope.js +++ b/server/lib/scope.js @@ -514,6 +514,7 @@ function broadcastAudience(type, data) { owners: laneOwners(d.lane?.id), viewers: laneViewers(d.lane?.id, { sessions: true }), }; + case "pane_presence": case "lane_hook_output": case "lane_hook_result": case "lane_runtime": diff --git a/server/openapi-extra/lanes-more.js b/server/openapi-extra/lanes-more.js index fe1dd3e1..c753c704 100644 --- a/server/openapi-extra/lanes-more.js +++ b/server/openapi-extra/lanes-more.js @@ -738,6 +738,17 @@ const paths = { }, }, }, + "/api/lanes/{id}/presence": { + get: { + tags: ["Lanes"], + summary: "Who has which of a workspace's panes open", + description: + "Server mode (local mode: `{panes: {}}`). `{panes: {: [{id, name}]}}` — the users with a terminal attached to each pane, from memory; changes arrive as the WebSocket message `pane_presence` `{laneId, runId, users}` (the workspace's owner and the users it is shared with).", + operationId: "getLanePresence", + parameters: [idParam], + responses: { 200: { description: "`{panes}`.", ...json(obj) }, 404: notFound }, + }, + }, "/api/lanes/{id}/sessions/{sid}/shares": { get: { tags: ["Lanes"], diff --git a/server/routes/lanes.js b/server/routes/lanes.js index bf4493ac..4b7059ba 100644 --- a/server/routes/lanes.js +++ b/server/routes/lanes.js @@ -1521,6 +1521,16 @@ router.put("/:id/shares", sameOriginGuard, (req, res) => { if (shares) res.json({ shares }); }); +// Who has which of this workspace's panes open (pane-presence.js): server +// mode's `{panes: {runId: [{id, name}]}}`; changes come as `pane_presence`. +router.get("/:id/presence", (req, res) => { + const lane = lanesLib.getLane(req.params.id); + if (!lane) + return res.status(404).json({ error: { code: "ENOLANE", message: "workspace not found" } }); + if (!require("../lib/auth").isServerMode()) return res.json({ panes: {} }); + res.json({ panes: require("../lib/pane-presence").forLane(lane.id) }); +}); + /** A session of this workspace (`sessions.lane_id`), or null after sending 404. */ function laneSession(req, res, lane) { const row = require("../db") diff --git a/server/websocket.js b/server/websocket.js index 44faeae1..f315e389 100644 --- a/server/websocket.js +++ b/server/websocket.js @@ -159,6 +159,33 @@ function initPtyWebSocket(server) { if (cookie !== undefined) { ws.cookie = cookie; ws.runId = runId; + // A browser that vanished without a close (a sleeping laptop) answers + // no ping: the beat below ends it, and its presence with it. + ws.isAlive = true; + ws.on("pong", () => { + ws.isAlive = true; + }); + } + // Server mode: who has this pane open (pane-presence.js), and the first + // keystroke of a collaborator-era attach written to the audit log. + if (cookie !== undefined) { + const presence = require("./lib/pane-presence"); + const user = userFromRequest({ headers: { cookie } }); + if (user) { + presence.join(runId, user); + ws.on("close", () => presence.leave(runId, user)); + let typed = false; + ws.on("message", (data, isBinary) => { + if (typed) return; + const binary = typeof isBinary === "boolean" ? isBinary : false; + const text = data.toString("utf8"); + if (!binary && text.startsWith('{"type"')) return; // a control frame + // A mouse report, a focus change or the terminal answering tmux. + if (!require("./lib/pty-attach").isTyping(text)) return; + typed = true; + presence.auditInput(runId, user); + }); + } } try { attach(ws, runId, size || { cols: 80, rows: 24 }); @@ -221,13 +248,20 @@ function initPtyWebSocket(server) { ptyWss.clients.forEach((ws) => { if (ws.cookie === undefined) return; const user = userFromRequest({ headers: { cookie: ws.cookie } }); - if (!user || !canSeeRun({ user, method: "POST" }, ws.runId)) { + if (!user || !canSeeRun({ user, method: "POST" }, ws.runId) || ws.isAlive === false) { try { ws.close(1008, "log in again"); } catch { /* already gone */ } ws.terminate(); + return; + } + ws.isAlive = false; + try { + ws.ping(); + } catch { + /* closing */ } }); }, PTY_RECHECK_MS);