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 <name>" 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 <run id>` (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.
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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<typeof useAiTools>;
|
||||
|
||||
@@ -234,6 +236,19 @@ export function ConsoleTabs({
|
||||
const tabRefs = useRef(new Map<string, HTMLButtonElement>());
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const tabCtx = useContextMenu<Tab>();
|
||||
// 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<number, { id: number; name: string }>();
|
||||
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<Tab[]>(
|
||||
@@ -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"
|
||||
>
|
||||
<span className="flex shrink-0 items-center gap-1">
|
||||
@@ -627,6 +649,23 @@ export function ConsoleTabs({
|
||||
))}
|
||||
</span>
|
||||
<span className="truncate">{tabLabel(tab)}</span>
|
||||
{others.length > 0 && (
|
||||
<span
|
||||
data-testid={`console-tab-people-${tab.key}`}
|
||||
className="flex shrink-0 -space-x-1"
|
||||
aria-label={t("tabs.here", { names: others.map((u) => u.name).join(", ") })}
|
||||
>
|
||||
{others.slice(0, 3).map(({ id, name }) => (
|
||||
<span
|
||||
key={id}
|
||||
aria-hidden
|
||||
className="flex h-4 w-4 items-center justify-center rounded-full border border-surface-1 bg-accent-solid text-[9px] font-semibold uppercase text-white"
|
||||
>
|
||||
{name.slice(0, 1)}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
{state && <RunDot state={state} />}
|
||||
</button>
|
||||
{tab.runs.some(canSleep) && (
|
||||
|
||||
@@ -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ĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
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(
|
||||
<MemoryRouter>
|
||||
<ConsoleTabs
|
||||
lane={LANE}
|
||||
tabs={[RUN]}
|
||||
groups={GROUPS}
|
||||
onGroupsChange={vi.fn()}
|
||||
onOpen={vi.fn()}
|
||||
onClosed={vi.fn()}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
);
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -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ĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { api } from "../lib/api";
|
||||
import { eventBus } from "../lib/eventBus";
|
||||
import { usePermissions } from "../components/AuthGate";
|
||||
|
||||
export type PanePeople = Record<string, { id: number; name: string }[]>;
|
||||
|
||||
export function usePanePresence(laneId: number | null | undefined): PanePeople {
|
||||
const { local } = usePermissions();
|
||||
const [panes, setPanes] = useState<PanePeople>({});
|
||||
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;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<string, { id: number; name: string }[]> }>(
|
||||
`/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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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. */
|
||||
|
||||
+11
@@ -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: { "<run id>": [{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 <run id>`).
|
||||
|
||||
```http
|
||||
GET /api/lanes/:id/sessions/:sid/shares → { shares, canManage }
|
||||
PUT /api/lanes/:id/sessions/:sid/shares {"userIds": [3]} → { shares }
|
||||
|
||||
@@ -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 <name>" 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 <run id>` —
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: {<run id>: [{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:
|
||||
|
||||
@@ -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ĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
|
||||
@@ -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 <run id>`) — who
|
||||
* typed, never what.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
/** 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 };
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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: {<run id>: [{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"],
|
||||
|
||||
@@ -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")
|
||||
|
||||
+35
-1
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user