Files
AgentHub/server/lib/pane-presence.js
T
nntrivi2001 18e574a765 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.
2026-11-06 11:32:25 +07:00

112 lines
3.4 KiB
JavaScript

/**
* @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 };