Compare commits

...

12 Commits

Author SHA1 Message Date
nntrivi2001 efe21c79e9 chore: v2.8.43 — terminal glyphs stay sharp in every pane; a huge diff line no longer hides the rest 2026-10-30 10:34:51 +07:00
nntrivi2001 ee119fe437 fix(terminal): restamp every glyph-atlas page so no pane keeps a stale texture
v2.8.42 reacted to a page merge by toggling the font family on the next
frame, so each renderer re-acquired an atlas. Panes still showed squeezed,
overlapping glyphs: the toggle lands a frame late, swaps every pane onto a
fresh atlas (or onto one another pane already toggled to), and a page added
without a merge shifts nothing yet still raced it.

addon-webgl 0.20 fixes the root cause with globally monotonic page versions.
Do the same on 0.19: whenever the shared atlas adds, removes or merges a page,
give every page a version no texture slot has uploaded, synchronously, so
every renderer on that atlas re-uploads all pages before it draws again.
When the page comes back from the background, restamp and repaint. An addon
of another shape keeps the old font-toggle fallback.
2026-10-30 10:33:57 +07:00
nntrivi2001 d97c403aab fix(changes): a huge line no longer cuts the diff short; preview pages up to 64 MB
A page that inlines a video as base64 has rows of megabytes. Its patch was read
at the 200 KB per-file cap, so the first such row used the whole budget and
every hunk after it was lost ("Diff cut short"). The patch is now read up to
16 M characters, rows over 4,000 characters are cut to 1,000 with an `elided`
count the diff shows, and only then is the cap applied. Hunk actions re-read
git's own diff by header and are unchanged.

The HTML preview refused a page over 10 MB and showed only the file icon; a
self-contained 18 MB slide deck now opens (limit 64 MB).
2026-10-30 10:28:53 +07:00
nntrivi2001 c41114faf8 chore: v2.8.42 — terminal glyphs stay sharp after the WebGL atlas fills up 2026-10-30 10:10:33 +07:00
nntrivi2001 c850f7603a fix(terminal): re-upload the shared WebGL atlas after a page merge
@xterm/addon-webgl 0.19 shares one glyph atlas between every terminal
with the same font. When it fills, four pages are merged into a larger
one and the rest shift down; a GPU texture is re-uploaded only when its
page version differs, and those counters can coincide, so a slot keeps a
stale page of another size and every glyph is drawn squeezed or foreign
(fixed upstream only in the 0.20 betas). On a merge — and when the page
comes back — toggle the font family's trailing space on the next frame,
which makes each renderer re-acquire its atlas and re-upload all pages.

This also drops yesterday's clearTextureAtlas on focus: it emptied the
SHARED atlas but redrew only the calling pane, corrupting its siblings.
2026-10-30 10:09:28 +07:00
nntrivi2001 5463e354fa chore: v2.8.41 — a session outside every workspace counts toward its user's spend 2026-10-30 08:50:58 +07:00
nntrivi2001 783b7f4101 fix(cost): bill a session outside every workspace to its Linux account's user
A script's claude -p in a temp folder (the SG Flow eval runner strips the
pane's env) had no workspace, so its spend reached nobody's Today, the Users
page or a spend quota — ~$57 of a $74 day was missing from the status bar.

sessions.account records the Linux account a session ran as (the hook's
peer uid, else its folder for poller-made and old rows); a session no
workspace owns is owned by that account's user — a member's to them, the
host's to the admin ticked 'Owns this server's host account' (or the only
admin). Workspace ownership still wins.
2026-10-30 08:50:34 +07:00
nntrivi2001 c28395402b chore: v2.8.40 — a Codex account switch reaches running sessions 2026-10-29 15:59:19 +07:00
nntrivi2001 7798530528 fix(profiles): a Codex switch reaches running and new sessions
Codex >= 0.164 sends every request through a shared app-server daemon that
outlives its TUIs and keeps the login it started with, so writing a new
auth.json changed nothing: open sessions and even new ones stayed on the
old (out-of-quota) account. After a switch, restart that CODEX_HOME's
daemon when one runs; open TUIs reconnect to it on their own (verified on
0.164.0 with two keys). The response says so (following), and the toast
tells the user running sessions follow.
2026-10-29 15:54:20 +07:00
nntrivi2001 25f63d9ad3 fix(terminal): rebuild the WebGL glyph atlas when the page comes back
A browser can drop the atlas's GPU memory without a context-loss event
(sleep/resume, a long-backgrounded tab, display/zoom change on macOS),
leaving every cell painted with a clipped or foreign glyph.
2026-10-29 15:44:16 +07:00
nntrivi2001 d060d288fb fix(sessions): Top tools counts each tool call once
The session analysis page's Top tools list counted every event carrying a
tool_name, so each call was counted twice (its PreToolUse and its
PostToolUse) while the Tool calls total beside it counted once — a session
with 840 calls showed Bash at 1,533. The list now counts the same way as
the total: PreToolUse, or PostToolUse in a session with none (imported).
2026-10-29 14:53:45 +07:00
nntrivi2001 6099841cef fix(sessions): archiving a live session stops its pane
Archive on a session a live pane held only hid its row, and the list
deliberately does not bring such a tab back, so the pane kept running
with nothing on screen showing it — a member archived five sessions
believing them closed and kept ~2.7 GB of claude/omp/postgres-mcp alive
for up to two days. Archive on a live session is now a Stop (endRuns,
the tab X's confirm, DELETE /api/run/:id?archive=1) in the sidebar row,
the workspace home's Recents and their right-click menu.
2026-10-29 14:26:25 +07:00
47 changed files with 4049 additions and 91 deletions
+6 -5
View File
@@ -1,4 +1,8 @@
{ {
"env": {
"ANTHROPIC_BASE_URL": "",
"ANTHROPIC_AUTH_TOKEN": ""
},
"permissions": { "permissions": {
"allow": [ "allow": [
"Bash(npm run:*)", "Bash(npm run:*)",
@@ -36,11 +40,8 @@
"Bash(node -e ':*)", "Bash(node -e ':*)",
"Bash(node -e \"require\\('./server/lib/transcript-cache'\\); require\\('./scripts/import-history'\\); console.log\\('All modules load OK'\\)\")", "Bash(node -e \"require\\('./server/lib/transcript-cache'\\); require\\('./scripts/import-history'\\); console.log\\('All modules load OK'\\)\")",
"Bash(claude --version)", "Bash(claude --version)",
"Bash(gh pr *)" "Bash(gh pr *)",
"Bash(grep *)"
] ]
},
"env": {
"ANTHROPIC_BASE_URL": "",
"ANTHROPIC_AUTH_TOKEN": ""
} }
} }
+2 -2
View File
@@ -1,6 +1,6 @@
# Claude Code Working Guide # Claude Code Working Guide
_Last synced: v2.8.39 (2026-10-29) — a display name may not read as another account's (ENAMETAKEN)._ 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.41 (2026-10-30) — a session outside every workspace is billed to the user of the Linux account it ran as._ 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 ## Project mission
- Maintain a reliable local-first dashboard for Claude Code session monitoring. - Maintain a reliable local-first dashboard for Claude Code session monitoring.
@@ -62,7 +62,7 @@ A **workspace** — called a **lane** in the API, CLI, database and code, which
- **Server tests never touch the user's tmux.** Run them through `npm run test:server` (`scripts/test-server.js`), never a bare `node --test server/__tests__/*.test.js` from inside a lane pane — the suites create and kill real `agenthub-lane-<id>` sessions. - **Server tests never touch the user's tmux.** Run them through `npm run test:server` (`scripts/test-server.js`), never a bare `node --test server/__tests__/*.test.js` from inside a lane pane — the suites create and kill real `agenthub-lane-<id>` sessions.
- **An artifact is shared as a SNAPSHOT and installed only into the viewer's own account.** `server/lib/artifacts.js` (`/api/artifacts`): a pinned file or a skill is copied once into `<data dir>/artifacts/<random>/` and every viewer reads only that copy (an owner's edit of a skill — in the Hub, or in the skill's source folder, which the server takes when the Hub is listed and an installer's differing copy is then offered **Update** — writes a NEW snapshot and drops the old one — never a write into the one being read; the newer of the two changes wins) — `raw` serves only a file the snapshot recorded, under a `sandbox` CSP + `nosniff`; a `page` is a `https://claude.ai/…` link only. A developer sees their own rows plus shared ones; only the owner or an admin changes one. Install writes a skill into BOTH `~/.claude/skills/` and the cross-tool `~/.agents/skills/` of every account the CALLER's sessions run as (`installAccounts`: their S3 member home, plus the host's for an admin, whose host-folder workspaces run as the host; S3: as them through `write-as`), never over an existing one without `replace` (the old one is moved to `~/.claude/agenthub-replaced-skills/`, never deleted), and runs a plugin's `claude plugin marketplace add`/`install --scope user` as the caller, never with `-y`; Uninstall (`POST /api/artifacts/:id/uninstall`) takes it out of the same accounts — a skill MOVED to `agenthub-replaced-skills/`, never deleted, a plugin `claude plugin uninstall --scope user --keep-data`; an admin's switch (Settings → AI tools, `ArtifactsHubSettings`) turns installs off. An uploaded or written skill is snapshotted under the same checks (paths relative, no `..`, no secret names, zip sizes read before inflating). `POST /api/artifacts/plugin/inspect` clones a user-named https link only to read `marketplace.json`, with every credential helper OFF — never send a login with it. Do not add a route that serves a live file of another user, installs into someone else's account, or takes a plugin command from the request. - **An artifact is shared as a SNAPSHOT and installed only into the viewer's own account.** `server/lib/artifacts.js` (`/api/artifacts`): a pinned file or a skill is copied once into `<data dir>/artifacts/<random>/` and every viewer reads only that copy (an owner's edit of a skill — in the Hub, or in the skill's source folder, which the server takes when the Hub is listed and an installer's differing copy is then offered **Update** — writes a NEW snapshot and drops the old one — never a write into the one being read; the newer of the two changes wins) — `raw` serves only a file the snapshot recorded, under a `sandbox` CSP + `nosniff`; a `page` is a `https://claude.ai/…` link only. A developer sees their own rows plus shared ones; only the owner or an admin changes one. Install writes a skill into BOTH `~/.claude/skills/` and the cross-tool `~/.agents/skills/` of every account the CALLER's sessions run as (`installAccounts`: their S3 member home, plus the host's for an admin, whose host-folder workspaces run as the host; S3: as them through `write-as`), never over an existing one without `replace` (the old one is moved to `~/.claude/agenthub-replaced-skills/`, never deleted), and runs a plugin's `claude plugin marketplace add`/`install --scope user` as the caller, never with `-y`; Uninstall (`POST /api/artifacts/:id/uninstall`) takes it out of the same accounts — a skill MOVED to `agenthub-replaced-skills/`, never deleted, a plugin `claude plugin uninstall --scope user --keep-data`; an admin's switch (Settings → AI tools, `ArtifactsHubSettings`) turns installs off. An uploaded or written skill is snapshotted under the same checks (paths relative, no `..`, no secret names, zip sizes read before inflating). `POST /api/artifacts/plugin/inspect` clones a user-named https link only to read `marketplace.json`, with every credential helper OFF — never send a login with it. Do not add a route that serves a live file of another user, installs into someone else's account, or takes a plugin command from the request.
- **A workspace's instructions, extra folders and context files are lane facts, and a pane reads them only from the lane's rows.** The one addition is the workspace OWNER's own agent instructions (`user_prefs.instructions`, `PUT /api/me/instructions`, Settings → AI tools), which `launchExtras` puts first — keyed by `lanes.owner_id` (`local` in local mode), never by whoever clicked Start. `lanes.instructions` and `lane_sources` (`server/lib/lane-sources.js`) reach a pane through `launchExtras(laneId, tool)`, which `spawnRun` calls ITSELF — so neither spawn path can take them from a request — and every write is same-origin (`server/routes/lane-sources.js`). A folder must be absolute, exist, be a directory, not be or contain the OS home (`validateHomeDir`) and not be the workspace's own folder; it is the user's, and AgentHub never deletes it. An uploaded file is stored under `<data dir>/workspace-context/<lane id>/` under a name AgentHub derives (never a path from the request), and those copies are the only thing here AgentHub deletes (`ownsFile`): with their row, and with the workspace in `deleteLane`. Claude's `--add-dir` is VARIADIC, so it goes first in the argv where `--permission-mode` ends it, and the text (instructions + context file list + extra folders) is written at every spawn as the ONLY file `CLAUDE.md` of `<data dir>/workspace-prompts/<lane id>/` (a member's: `prompt-<lane id>/` in their handoff dir, via `write-as`), which goes in as one more `--add-dir` with `CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD=1` in the pane env — never `--append-system-prompt-file`: a custom system prompt is a launch flag Claude cannot carry over a restart, so it refused `/tui` in every pane with instructions (side effect: a CLAUDE.md in an extra folder is now read too); Codex gets one `--add-dir` per directory and `-c developer_instructions=<JSON string>` — the text itself up to 6 KB, else a line pointing at that file. Only `claude`/`codex` take them. Never write them into the user's repo (CLAUDE.md/AGENTS.md). - **A workspace's instructions, extra folders and context files are lane facts, and a pane reads them only from the lane's rows.** The one addition is the workspace OWNER's own agent instructions (`user_prefs.instructions`, `PUT /api/me/instructions`, Settings → AI tools), which `launchExtras` puts first — keyed by `lanes.owner_id` (`local` in local mode), never by whoever clicked Start. `lanes.instructions` and `lane_sources` (`server/lib/lane-sources.js`) reach a pane through `launchExtras(laneId, tool)`, which `spawnRun` calls ITSELF — so neither spawn path can take them from a request — and every write is same-origin (`server/routes/lane-sources.js`). A folder must be absolute, exist, be a directory, not be or contain the OS home (`validateHomeDir`) and not be the workspace's own folder; it is the user's, and AgentHub never deletes it. An uploaded file is stored under `<data dir>/workspace-context/<lane id>/` under a name AgentHub derives (never a path from the request), and those copies are the only thing here AgentHub deletes (`ownsFile`): with their row, and with the workspace in `deleteLane`. Claude's `--add-dir` is VARIADIC, so it goes first in the argv where `--permission-mode` ends it, and the text (instructions + context file list + extra folders) is written at every spawn as the ONLY file `CLAUDE.md` of `<data dir>/workspace-prompts/<lane id>/` (a member's: `prompt-<lane id>/` in their handoff dir, via `write-as`), which goes in as one more `--add-dir` with `CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD=1` in the pane env — never `--append-system-prompt-file`: a custom system prompt is a launch flag Claude cannot carry over a restart, so it refused `/tui` in every pane with instructions (side effect: a CLAUDE.md in an extra folder is now read too); Codex gets one `--add-dir` per directory and `-c developer_instructions=<JSON string>` — the text itself up to 6 KB, else a line pointing at that file. Only `claude`/`codex` take them. Never write them into the user's repo (CLAUDE.md/AGENTS.md).
- **A workspace's sessions are the ones it OWNS, never the ones that ran in its folder.** `sessions.lane_id` is stamped by `bindRunSession` (`server/routes/hooks.js`) for the session a dashboard pane holds — first writer wins, a nested `claude -p` excluded — and no route sets it. A Codex/Antigravity session (their hooks are opt-in, so a pane may never learn its session) is also stamped by `claimToolSessions` (`server/lib/dashboard-runs.js`, called by both pollers and once at start): the lane of the newest same-tool dashboard run in the session's folder that was live when the session started, never a Claude session. A new Claude pane is started with `--session-id <uuid>` (`spawnRun`), so its `dashboard_runs.session_id` is known before any hook and a hook that carries no `run_id` (a hook handler older than `AGENTHUB_RUN_ID` forwarding) is still stamped from that run (`laneForSession`). Every workspace session list reads `GET /api/sessions?lane=<id>` — which also counts a still-unstamped session one of the lane's `dashboard_runs` holds, because hooks reach only the lowest-port dashboard on a shared DB and that one may be an older build that never stamps — and shows nothing else (no "other sessions in this folder"). Do not go back to listing a workspace by `cwd`, and do not guess ownership for old history. - **A workspace's sessions are the ones it OWNS, never the ones that ran in its folder.** `sessions.lane_id` is stamped by `bindRunSession` (`server/routes/hooks.js`) for the session a dashboard pane holds — first writer wins, a nested `claude -p` excluded — and no route sets it. A Codex/Antigravity session (their hooks are opt-in, so a pane may never learn its session) is also stamped by `claimToolSessions` (`server/lib/dashboard-runs.js`, called by both pollers and once at start): the lane of the newest same-tool dashboard run in the session's folder that was live when the session started, never a Claude session. A new Claude pane is started with `--session-id <uuid>` (`spawnRun`), so its `dashboard_runs.session_id` is known before any hook and a hook that carries no `run_id` (a hook handler older than `AGENTHUB_RUN_ID` forwarding) is still stamped from that run (`laneForSession`). Every workspace session list reads `GET /api/sessions?lane=<id>` — which also counts a still-unstamped session one of the lane's `dashboard_runs` holds, because hooks reach only the lowest-port dashboard on a shared DB and that one may be an older build that never stamps — and shows nothing else (no "other sessions in this folder"). Do not go back to listing a workspace by `cwd`, and do not guess ownership for old history. Billing has one more owner: a session NO workspace owns belongs to the user of the Linux account it ran as (`sessions.account` — the hook's peer, else its folder; `server/lib/session-account.js`, the host's account → the admin marked `users.host_account`, else the only admin), so a script's `claude -p` counts in Today, the Users page and spend quotas — a workspace's ownership always wins over it.
- **A session's worktree is `claude --worktree`'s, resolved from the session row — and removing it never forces.** A Worktree start passes `--worktree=<server-made name>` (the `=` form: `-w [name]` eats the prompt otherwise) and Claude Code makes, populates and cleans up `<repo>/.claude/worktrees/<name>`; AgentHub only excludes `/.claude/worktrees/` at the repo root. A git route, a resume or a removal may work in a session's worktree only through `sessionWorktree` (`server/lib/session-worktree.js`: the session owned by the lane, its cwd not the lane's folder, registered by `git worktree list`) — `?session=` on the git routes answers `ENOWORKTREE` rather than falling back to the main folder. `POST /api/lanes/:id/session-worktree/remove` (same-origin, `confirm: true`, refused while a live pane holds the session) is the ONE dashboard path that deletes a worktree: `git worktree remove` with no `--force`, `git branch -d` never `-D`, a live-pid lock refused. Do not add a force, and do not let it touch a lane's own folder. - **A session's worktree is `claude --worktree`'s, resolved from the session row — and removing it never forces.** A Worktree start passes `--worktree=<server-made name>` (the `=` form: `-w [name]` eats the prompt otherwise) and Claude Code makes, populates and cleans up `<repo>/.claude/worktrees/<name>`; AgentHub only excludes `/.claude/worktrees/` at the repo root. A git route, a resume or a removal may work in a session's worktree only through `sessionWorktree` (`server/lib/session-worktree.js`: the session owned by the lane, its cwd not the lane's folder, registered by `git worktree list`) — `?session=` on the git routes answers `ENOWORKTREE` rather than falling back to the main folder. `POST /api/lanes/:id/session-worktree/remove` (same-origin, `confirm: true`, refused while a live pane holds the session) is the ONE dashboard path that deletes a worktree: `git worktree remove` with no `--force`, `git branch -d` never `-D`, a live-pid lock refused. Do not add a force, and do not let it touch a lane's own folder.
- **Opening a session never resumes it — only SENDING a message to it does.** The sidebar and a workspace home ATTACH pane 0 to a session's live tmux pane (`/workspace?lane=<id>&run=<runId>`, `attachRequest` on `LaneConsolePane`) or, for an ended one, open it in its workspace like Claude Desktop (`/workspace?lane=<id>&session=<id>`, `client/src/components/lanes/SessionReplay.tsx`: its conversation read-only plus a prompt box; the console is only hidden, CSS). A resume re-reads the whole transcript into context, so it happens only on an explicit action: a message SENT from that replay (`resumeSessionId` + `prompt`, first free slot), the transcript page's **Resume**/**Fork** (`SessionResumeButton.tsx`, behind a warning), or the ticked sessions of the **Resume interrupted sessions?** dialog (`InterruptedSessionsPrompt.tsx`, `GET /api/run/interrupted` — runs whose pane died under them; opening it resumes nothing). The analysis page `/sessions/<id>` is reached from the session view's **Analyst** button (beside Files/Changes in the right panel). Do not add a path that resumes on a click. A resume is refused while a live pane holds the session (`409 ESESSIONLIVE` with that pane's `run_id`) — open that pane instead; a fork makes a new session and is allowed. - **Opening a session never resumes it — only SENDING a message to it does.** The sidebar and a workspace home ATTACH pane 0 to a session's live tmux pane (`/workspace?lane=<id>&run=<runId>`, `attachRequest` on `LaneConsolePane`) or, for an ended one, open it in its workspace like Claude Desktop (`/workspace?lane=<id>&session=<id>`, `client/src/components/lanes/SessionReplay.tsx`: its conversation read-only plus a prompt box; the console is only hidden, CSS). A resume re-reads the whole transcript into context, so it happens only on an explicit action: a message SENT from that replay (`resumeSessionId` + `prompt`, first free slot), the transcript page's **Resume**/**Fork** (`SessionResumeButton.tsx`, behind a warning), or the ticked sessions of the **Resume interrupted sessions?** dialog (`InterruptedSessionsPrompt.tsx`, `GET /api/run/interrupted` — runs whose pane died under them; opening it resumes nothing). The analysis page `/sessions/<id>` is reached from the session view's **Analyst** button (beside Files/Changes in the right panel). Do not add a path that resumes on a click. A resume is refused while a live pane holds the session (`409 ESESSIONLIVE` with that pane's `run_id`) — open that pane instead; a fork makes a new session and is allowed.
- **A chat artifact runs in its own sandbox, never on the dashboard's origin.** The artifact panel (`client/src/components/run/ChatFilePreview.tsx`, `client/src/lib/artifactView.ts`) runs model-written HTML/Mermaid/React in an `<iframe srcdoc sandbox="allow-scripts …">` and an SVG in `sandbox=""` — never add `allow-same-origin` (the script would read the dashboard's cookies and call its API as the user), never render one with `dangerouslySetInnerHTML`, and keep its CDN libraries pinned to exact versions. An HTML FILE opened from the Changes panel, a Chat file preview or the File Explorer runs in the same sandbox (`client/src/components/HtmlFrame.tsx`, shown in a modal — `HtmlViewerModal.tsx`, Chat's `FilePreview modal`); its relative CSS/scripts/images are fetched by the DASHBOARD through the existing download routes and inlined (`client/src/lib/htmlInline.ts`) — never add a route that serves a folder to the frame without the login cookie. Versions are rebuilt from Write/Edit inputs (kept up to 256 KB by `server/lib/transcript-message.js`); no copy is stored, and Save to Artifacts uses the Hub's own `pinFile`/upload routes. - **A chat artifact runs in its own sandbox, never on the dashboard's origin.** The artifact panel (`client/src/components/run/ChatFilePreview.tsx`, `client/src/lib/artifactView.ts`) runs model-written HTML/Mermaid/React in an `<iframe srcdoc sandbox="allow-scripts …">` and an SVG in `sandbox=""` — never add `allow-same-origin` (the script would read the dashboard's cookies and call its API as the user), never render one with `dangerouslySetInnerHTML`, and keep its CDN libraries pinned to exact versions. An HTML FILE opened from the Changes panel, a Chat file preview or the File Explorer runs in the same sandbox (`client/src/components/HtmlFrame.tsx`, shown in a modal — `HtmlViewerModal.tsx`, Chat's `FilePreview modal`); its relative CSS/scripts/images are fetched by the DASHBOARD through the existing download routes and inlined (`client/src/lib/htmlInline.ts`) — never add a route that serves a folder to the frame without the login cookie. Versions are rebuilt from Write/Edit inputs (kept up to 256 KB by `server/lib/transcript-message.js`); no copy is stored, and Save to Artifacts uses the Hub's own `pinFile`/upload routes.
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
+4 -2
View File
@@ -15,8 +15,10 @@ import { FileText, Loader2 } from "lucide-react";
import { artifactDocument, frameSandbox } from "../lib/artifactView"; import { artifactDocument, frameSandbox } from "../lib/artifactView";
import { blobText, inlineHtml, type FetchFile } from "../lib/htmlInline"; import { blobText, inlineHtml, type FetchFile } from "../lib/htmlInline";
/** A page read by `fetchFile` is shown up to this size. */ /** A page read by `fetchFile` is shown up to this size. A self-contained
const HTML_MAX = 10 * 1024 * 1024; * page (a slide deck with its video and images inlined as base64) is easily
* past 10 MB; the iframe renders far larger documents than this. */
const HTML_MAX = 64 * 1024 * 1024;
export function HtmlFrame({ export function HtmlFrame({
path, path,
@@ -3,7 +3,8 @@
* @description Tests for the sidebar's workspace list: one flat row per * @description Tests for the sidebar's workspace list: one flat row per
* workspace in the stored order, a row expanding to its sessions (remembered), * workspace in the stored order, a row expanding to its sessions (remembered),
* a live session opening its pane and an ended one its transcript, the * a live session opening its pane and an ended one its transcript, the
* workspace's OWN sessions only, a session's state dot, rename and archive, * workspace's OWN sessions only, a session's state dot, rename and archive
* (a live one's pane stopped first),
* selection * selection
* by URL, Alt+arrow reordering kept in localStorage, double-click rename, the * by URL, Alt+arrow reordering kept in localStorage, double-click rename, the
* search, the row menu's items by kind and tool, the status dot, and the * search, the row menu's items by kind and tool, the status dot, and the
@@ -225,6 +226,8 @@ describe("WorkspaceTree", () => {
await user.type(input, "Better name{Enter}"); await user.type(input, "Better name{Enter}");
expect(api.sessions.patch).toHaveBeenCalledWith("s-old", { name: "Better name" }); expect(api.sessions.patch).toHaveBeenCalledWith("s-old", { name: "Better name" });
await user.click(screen.getByTestId("workspace-session-archive-s-live")); await user.click(screen.getByTestId("workspace-session-archive-s-live"));
// A live session's Archive stops its pane first — never a hidden pane left running.
expect(api.run.kill).toHaveBeenCalledWith("agenthub-lane-1-2", { archive: true });
expect(api.sessions.patch).toHaveBeenCalledWith("s-live", { archived: true }); expect(api.sessions.patch).toHaveBeenCalledWith("s-live", { archived: true });
}); });
@@ -179,6 +179,18 @@ export function langFor(filePath: string) {
} }
/** Tokenized line text. `emph` marks the span that actually changed. */ /** Tokenized line text. `emph` marks the span that actually changed. */
/** The tail of a row the server cut down (`LaneDiffLine.elided`). */
function Elided({ n }: { n?: number }) {
const { t, i18n } = useTranslation("lanes");
if (!n) return null;
const size = new Intl.NumberFormat(i18n.language, { notation: "compact" }).format(n);
return (
<span className="ml-1 select-none rounded bg-surface-3 px-1 font-sans text-[10px] text-fg-muted">
{t("changes.lineElided", { size })}
</span>
);
}
function Code({ text, lang, emph }: { text: string; lang: string; emph?: [number, number] }) { function Code({ text, lang, emph }: { text: string; lang: string; emph?: [number, number] }) {
if (emph) { if (emph) {
const [from, to] = emph; const [from, to] = emph;
@@ -544,6 +556,7 @@ function UnifiedLine({
{line.type === "add" ? "+" : line.type === "del" ? "-" : " "} {line.type === "add" ? "+" : line.type === "del" ? "-" : " "}
</span> </span>
<Code text={line.text} lang={lang} emph={emph} /> <Code text={line.text} lang={lang} emph={emph} />
<Elided n={line.elided} />
</span> </span>
{bar && <HunkBar change={bar} />} {bar && <HunkBar change={bar} />}
</Row> </Row>
@@ -621,6 +634,7 @@ function SplitLine({
/> />
<span className="whitespace-pre px-1"> <span className="whitespace-pre px-1">
{line ? <Code text={line.text} lang={lang} emph={e} /> : null} {line ? <Code text={line.text} lang={lang} emph={e} /> : null}
{line ? <Elided n={line.elided} /> : null}
</span> </span>
{bar && <HunkBar change={bar} />} {bar && <HunkBar change={bar} />}
</Row> </Row>
+17 -16
View File
@@ -14,7 +14,7 @@
* a catalog tool's bypass — a YOLO chip for Codex) and Local / New worktree — the workspace's Recents — the sessions it * a catalog tool's bypass — a YOLO chip for Codex) and Local / New worktree — the workspace's Recents — the sessions it
* owns and has not archived (`?lane=&archived=0`, with each one's state dot, * owns and has not archived (`?lane=&archived=0`, with each one's state dot,
* **Show more** past the first page, and the sidebar row's Rename/Archive in * **Show more** past the first page, and the sidebar row's Rename/Archive in
* its right-click menu), then the archived ones folded with Unarchive; a live session opens its pane, * its right-click menu — Archive on a live session stops its pane first, `endRuns`), then the archived ones folded with Unarchive; a live session opens its pane,
* any other its conversation (`&session=`, `SessionReplay`), nothing is resumed — and on the right its context: Instructions, Memory (read-only), Context files and * any other its conversation (`&session=`, `SessionReplay`), nothing is resumed — and on the right its context: Instructions, Memory (read-only), Context files and
* Folders (`WorkspaceContext.tsx`, also folded at the bottom of the Changes panel). * Folders (`WorkspaceContext.tsx`, also folded at the bottom of the Changes panel).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
@@ -22,6 +22,7 @@
import { isSubmitEnter } from "../../lib/keys"; import { isSubmitEnter } from "../../lib/keys";
import { confirmDialog } from "../../lib/confirm"; import { confirmDialog } from "../../lib/confirm";
import { endRuns } from "../../lib/stopSession";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
@@ -307,30 +308,30 @@ function HomeBody({
} }
}; };
const archiveSession = async (s: Session) => { const archiveSession = async (s: Session) => {
const live = !!liveRunFor(lane, liveRuns, s.id); const run = liveRunFor(lane, liveRuns, s.id);
// A live one is stopped first, as the tab's X does (its own confirm):
// archiving only its row left a pane running that no list showed.
if (run) {
if ((await endRuns([run], { name: s.name || t("tree.newSession"), t })).length)
await patchSession(s, { archived: true });
return;
}
const wt = sessionWorktreeName(lane, s); const wt = sessionWorktreeName(lane, s);
const ok = await confirmDialog( const ok = await confirmDialog(
wt && !live wt
? { ? {
title: t("tree.archiveWorktreeTitle"), title: t("tree.archiveWorktreeTitle"),
message: t("tree.archiveWorktreeBody", { name: wt }), message: t("tree.archiveWorktreeBody", { name: wt }),
confirmLabel: t("tree.archiveWorktreeConfirm"), confirmLabel: t("tree.archiveWorktreeConfirm"),
} }
: live : {
? { title: t("tree.archiveSession"),
title: t("tree.archiveLiveTitle"), confirmLabel: t("sessionMenu.archive"),
message: t("tree.archiveLiveBody"), destructive: false,
confirmLabel: t("sessionMenu.archive"), }
destructive: false,
}
: {
title: t("tree.archiveSession"),
confirmLabel: t("sessionMenu.archive"),
destructive: false,
}
); );
if (!ok) return; if (!ok) return;
if (wt && !live) { if (wt) {
try { try {
await api.lanes.removeSessionWorktree(lane.id, s.id).catch((err: unknown) => { await api.lanes.removeSessionWorktree(lane.id, s.id).catch((err: unknown) => {
if ((err as { code?: string }).code !== "ENOWORKTREE") throw err; if ((err as { code?: string }).code !== "ENOWORKTREE") throw err;
@@ -24,7 +24,7 @@
* The list shows `SESSION_LIMIT` rows and a **See all (N more)** under them that * The list shows `SESSION_LIMIT` rows and a **See all (N more)** under them that
* loads every one, then a **Show less** that goes back to `SESSION_LIMIT`. A * loads every one, then a **Show less** that goes back to `SESSION_LIMIT`. A
* live tab whose session was archived is not brought back as a New session, * live tab whose session was archived is not brought back as a New session,
* and archiving a live one asks first. F2 renames the focused row too; each * and archiving a live one STOPS it first (`endRuns`, the tab X's confirm). F2 renames the focused row too; each
* dot carries its meaning as screen-reader text. `readOnly` (another user's * dot carries its meaning as screen-reader text. `readOnly` (another user's
* workspace, for an analyst): no live tabs, no rename/archive/menu, and a row * workspace, for an analyst): no live tabs, no rename/archive/menu, and a row
* opens the session's analysis page (`/sessions/<id>`) — never a pane or a resume. * opens the session's analysis page (`/sessions/<id>`) — never a pane or a resume.
@@ -32,7 +32,7 @@
*/ */
import { useEffect, useState, type ReactNode } from "react"; import { useEffect, useState, type ReactNode } from "react";
import { confirmDialog } from "../../lib/confirm"; import { endRuns } from "../../lib/stopSession";
import { isSubmitEnter } from "../../lib/keys"; import { isSubmitEnter } from "../../lib/keys";
import { Archive, GitBranch, Pin, PinOff, SquareTerminal } from "lucide-react"; import { Archive, GitBranch, Pin, PinOff, SquareTerminal } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -363,17 +363,10 @@ export function WorkspaceSessionList({
}; };
const archive = async (s: Session) => { const archive = async (s: Session) => {
if (sessionWorktreeName(lane, s) && !runFor(s)) return setArchiving(s); if (sessionWorktreeName(lane, s) && !runFor(s)) return setArchiving(s);
// A live one keeps running in its tab; archiving only hides its row. // A live one is stopped first, as the tab's X does: archiving only its row
if ( // left a pane running that no list showed any more.
runFor(s) && const run = runFor(s);
!(await confirmDialog({ if (run && !(await endRuns([run], { name: s.name || t("tree.newSession"), t })).length) return;
title: t("tree.archiveLiveTitle"),
message: t("tree.archiveLiveBody"),
confirmLabel: t("sessionMenu.archive"),
destructive: false,
}))
)
return;
await patch(s, { archived: true }); await patch(s, { archived: true });
}; };
const archiveWithWorktree = async (s: Session) => { const archiveWithWorktree = async (s: Session) => {
@@ -609,3 +609,26 @@ describe("LaneDiffView — line comments", () => {
expect(screen.queryByTestId("diff-comment-line-1")).toBeNull(); expect(screen.queryByTestId("diff-comment-line-1")).toBeNull();
}); });
}); });
describe("LaneDiffView — a row the server cut down", () => {
const CUT: LaneChangeFile = {
...FILE,
hunks: [
{
...FILE.hunks![0]!,
lines: [
{ type: "del", oldLine: 1, newLine: null, text: '<video src="AAAA', elided: 3_000_000 },
{ type: "add", oldLine: null, newLine: 1, text: '<video src="BBBB', elided: 3_000_000 },
],
},
],
};
it("says how much of the row it hides, in both layouts", () => {
const { unmount } = render(<LaneDiffView file={CUT} layout="unified" />);
expect(screen.getAllByText(/3M more characters on this line/)).toHaveLength(2);
unmount();
render(<LaneDiffView file={CUT} layout="split" />);
expect(screen.getAllByText(/3M more characters on this line/)).toHaveLength(2);
});
});
+75 -1
View File
@@ -96,6 +96,36 @@ type DropState = "hint" | "paste" | "uploading" | "failed" | "tooBig" | "tooBigL
/** localStorage flag for xterm's screen-reader mode (a Settings toggle can set it later). */ /** localStorage flag for xterm's screen-reader mode (a Settings toggle can set it later). */
export const SCREEN_READER_KEY = "agenthub.terminal.screenReader"; export const SCREEN_READER_KEY = "agenthub.terminal.screenReader";
/** localStorage flag: the "Shift+drag selects text" hint was dismissed. */ /** localStorage flag: the "Shift+drag selects text" hint was dismissed. */
/** The atlas a WebGL addon draws from: @xterm/addon-webgl 0.19 keeps these
* fields unminified in its build. Undefined on any other shape. */
type AtlasPages = { pages: { version: number }[] };
const atlasOf = (addon: WebglAddon): AtlasPages | undefined => {
const atlas = (addon as unknown as { _renderer?: { _charAtlas?: AtlasPages } })._renderer
?._charAtlas;
return Array.isArray(atlas?.pages) ? atlas : undefined;
};
/** Last version handed out by `restampAtlas`, shared by every pane. */
let atlasStamp = 0;
/**
* Gives every page of the addon's glyph atlas a version no texture slot has
* uploaded, so EVERY renderer sharing that atlas re-uploads every page from
* its canvas on its next draw — what addon-webgl 0.20 does with globally
* monotonic page versions. 0.19 counts per page, so after a merge shifts the
* pages down a slot can hold a stale page whose counter happens to match, and
* glyphs are sampled squeezed or foreign. Steps of 2^24 stay clear of the
* per-glyph increments a page makes before the next restamp. False when the
* addon is not the shape this knows.
*/
const restampAtlas = (addon: WebglAddon): boolean => {
const atlas = atlasOf(addon);
if (!atlas) return false;
for (const page of atlas.pages) {
atlasStamp += 2 ** 24;
page.version = atlasStamp;
}
return true;
};
const SELECT_HINT_KEY = "agenthub.terminal.selectHintSeen"; const SELECT_HINT_KEY = "agenthub.terminal.selectHintSeen";
const readFlag = (key: string) => { const readFlag = (key: string) => {
try { try {
@@ -468,7 +498,8 @@ export const TerminalView = memo(function TerminalView({
// A terminal mounted on a hidden tab sits in a 0×0 box (`display:none`): // A terminal mounted on a hidden tab sits in a 0×0 box (`display:none`):
// fitting it there shrinks xterm to its 12×6 minimum, and that size would // fitting it there shrinks xterm to its 12×6 minimum, and that size would
// reach tmux with the attach. It stays unfitted until it is shown. // reach tmux with the attach. It stays unfitted until it is shown.
const hasBox = () => !!containerRef.current?.clientWidth && !!containerRef.current.clientHeight; const hasBox = () =>
!!containerRef.current?.clientWidth && !!containerRef.current.clientHeight;
if (hasBox()) fit.fit(); if (hasBox()) fit.fit();
// Take the keyboard as soon as the pane is on screen: the terminal IS the // Take the keyboard as soon as the pane is on screen: the terminal IS the
// page here, and having to click it first meant the first thing typed went // page here, and having to click it first meant the first thing typed went
@@ -761,6 +792,44 @@ export const TerminalView = memo(function TerminalView({
refit(); refit();
}; };
document.fonts?.addEventListener("loadingdone", onFontLoaded); document.fonts?.addEventListener("loadingdone", onFontLoaded);
// @xterm/addon-webgl 0.19 shares ONE glyph atlas between every terminal
// with the same font. When it fills up it merges four pages into one
// larger page and shifts the rest down, and a GPU texture is re-uploaded
// only when its page's `version` differs from the uploaded one — after a
// shift those per-page counters can coincide, so a slot keeps a stale
// page of another size and every glyph is drawn squeezed or foreign
// while the buffer stays correct (fixed upstream only in 0.20 betas).
// Any change to the page list therefore restamps every page's version
// (`restampAtlas`) synchronously, before the next draw of ANY pane on
// that atlas. `clearTextureAtlas` is no fix: it empties the SHARED atlas
// but redraws only this pane, leaving its siblings slicing reused space.
// An addon of another shape falls back to toggling the family's
// trailing space on the next frame, which makes each renderer re-acquire
// its atlas (as onFontLoaded does).
let atlasFrame = 0;
const rebuildAtlas = () => {
if (gpu && restampAtlas(gpu)) return true;
if (atlasFrame) return false;
// Never inside the render that is rasterizing the merging glyph.
atlasFrame = requestAnimationFrame(() => {
atlasFrame = 0;
const family = term.options.fontFamily ?? "";
term.options.fontFamily = family.endsWith(" ") ? family.trimEnd() : `${family} `;
});
return false;
};
const atlasMerged = gpu?.onRemoveTextureAtlasCanvas(rebuildAtlas);
const atlasGrew = gpu?.onAddTextureAtlasCanvas(rebuildAtlas);
// The browser may also drop the textures without a context-loss event
// (sleep/resume, a long-backgrounded tab): re-upload when the page is
// back, and repaint, since nothing else asks this pane to draw.
const onPageShown = () => {
if (document.visibilityState === "visible" && rebuildAtlas()) {
term.refresh(0, term.rows - 1);
}
};
document.addEventListener("visibilitychange", onPageShown);
window.addEventListener("focus", onPageShown);
// Settings → Appearance changed the terminal's font or size: an open // Settings → Appearance changed the terminal's font or size: an open
// pane takes it now, re-measured and re-fitted like a font landing. // pane takes it now, re-measured and re-fitted like a font landing.
const onFontPref = () => { const onFontPref = () => {
@@ -779,6 +848,11 @@ export const TerminalView = memo(function TerminalView({
searchRef.current = null; searchRef.current = null;
resizeObserver.disconnect(); resizeObserver.disconnect();
document.fonts?.removeEventListener("loadingdone", onFontLoaded); document.fonts?.removeEventListener("loadingdone", onFontLoaded);
if (atlasFrame) cancelAnimationFrame(atlasFrame);
atlasMerged?.dispose();
atlasGrew?.dispose();
document.removeEventListener("visibilitychange", onPageShown);
window.removeEventListener("focus", onPageShown);
window.removeEventListener(FONT_SIZE_EVENT, onFontPref); window.removeEventListener(FONT_SIZE_EVENT, onFontPref);
term.textarea?.removeEventListener("focus", reassert); term.textarea?.removeEventListener("focus", reassert);
detachIme(); detachIme();
@@ -63,6 +63,9 @@ const termState = {
}; };
const onResizeHandlers: Array<() => void> = []; const onResizeHandlers: Array<() => void> = [];
const termOptions: { theme?: unknown } = {}; const termOptions: { theme?: unknown } = {};
const refreshMock = vi.fn();
/** The atlas the next WebGL addon draws from; null = an addon of another shape. */
const nextAtlas: { pages: { version: number }[] | null } = { pages: null };
const ctorArgs: Array<{ theme?: unknown }> = []; const ctorArgs: Array<{ theme?: unknown }> = [];
vi.mock("@xterm/xterm", () => ({ vi.mock("@xterm/xterm", () => ({
@@ -92,6 +95,7 @@ vi.mock("@xterm/xterm", () => ({
return { dispose: vi.fn() }; return { dispose: vi.fn() };
}, },
dispose: disposeMock, dispose: disposeMock,
refresh: refreshMock,
loadAddon: loadAddonMock, loadAddon: loadAddonMock,
attachCustomWheelEventHandler: wheelHandlerMock, attachCustomWheelEventHandler: wheelHandlerMock,
attachCustomKeyEventHandler: keyHandlerMock, attachCustomKeyEventHandler: keyHandlerMock,
@@ -112,21 +116,44 @@ vi.mock("@xterm/addon-fit", () => ({
FitAddon: vi.fn().mockImplementation(() => ({ fit: vi.fn() })), FitAddon: vi.fn().mockImplementation(() => ({ fit: vi.fn() })),
})); }));
// The newest WebGL addon, with the context-loss listener the view gave it. // The newest WebGL addon, with the context-loss listener the view gave it.
const gpu: { last: { dispose: ReturnType<typeof vi.fn>; lose: () => void } | null } = { const gpu: {
last: {
dispose: ReturnType<typeof vi.fn>;
lose: () => void;
merge: () => void;
grow: () => void;
} | null;
} = {
last: null, last: null,
}; };
vi.mock("@xterm/addon-webgl", () => ({ vi.mock("@xterm/addon-webgl", () => ({
WebglAddon: vi.fn().mockImplementation(() => { WebglAddon: vi.fn().mockImplementation(() => {
let onLoss = () => {}; let onLoss = () => {};
let onMerge = () => {};
let onGrow = () => {};
const addon = { const addon = {
__webgl: true, __webgl: true,
...(nextAtlas.pages ? { _renderer: { _charAtlas: { pages: nextAtlas.pages } } } : {}),
dispose: vi.fn(), dispose: vi.fn(),
onContextLoss: (fn: () => void) => { onContextLoss: (fn: () => void) => {
onLoss = fn; onLoss = fn;
return { dispose: vi.fn() }; return { dispose: vi.fn() };
}, },
onRemoveTextureAtlasCanvas: (fn: () => void) => {
onMerge = fn;
return { dispose: vi.fn() };
},
onAddTextureAtlasCanvas: (fn: () => void) => {
onGrow = fn;
return { dispose: vi.fn() };
},
};
gpu.last = {
dispose: addon.dispose,
lose: () => onLoss(),
merge: () => onMerge(),
grow: () => onGrow(),
}; };
gpu.last = { dispose: addon.dispose, lose: () => onLoss() };
return addon; return addon;
}), }),
})); }));
@@ -488,6 +515,50 @@ describe("TerminalView", () => {
expect(disposeMock).not.toHaveBeenCalled(); // the terminal itself lives on expect(disposeMock).not.toHaveBeenCalled(); // the terminal itself lives on
}); });
it("restamps every atlas page at once when the page list changes", () => {
// addon-webgl 0.19 counts versions per page, so after a merge shifts the
// pages a texture slot can keep a stale page whose counter matches. Every
// page gets a version no slot has uploaded — in every pane on the atlas.
const pages = [{ version: 3 }, { version: 7 }, { version: 1 }];
nextAtlas.pages = pages;
const raf = vi.spyOn(window, "requestAnimationFrame");
try {
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
gpu.last!.merge();
const first = pages.map((p) => p.version);
expect(new Set(first).size).toBe(3);
expect(first.every((v) => v > 7)).toBe(true);
gpu.last!.grow();
expect(pages.every((p, i) => p.version > Math.max(...first) && p.version !== first[i])).toBe(
true
);
expect(raf).not.toHaveBeenCalled(); // synchronous, before any pane draws
// The page coming back re-uploads and repaints this pane.
refreshMock.mockClear();
window.dispatchEvent(new Event("focus"));
expect(refreshMock).toHaveBeenCalledWith(0, 27);
} finally {
nextAtlas.pages = null;
raf.mockRestore();
}
});
it("re-acquires the glyph atlas on the next frame after a page merge", () => {
// An addon whose atlas this cannot reach: any option change makes each
// renderer re-acquire its atlas and re-upload every page.
const raf = vi.spyOn(window, "requestAnimationFrame").mockImplementation(() => 1);
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
const opts = termOptions as { fontFamily?: string };
opts.fontFamily = "JetBrains Mono";
gpu.last!.merge();
gpu.last!.merge(); // coalesced into one frame
expect(raf).toHaveBeenCalledTimes(1);
expect(opts.fontFamily).toBe("JetBrains Mono"); // never inside the render that merged
(raf.mock.calls[0]![0] as FrameRequestCallback)(0);
expect(opts.fontFamily).toBe("JetBrains Mono ");
raf.mockRestore();
});
// xterm 6 dropped its own Alt+arrow → word-move mapping; the pane keeps it. // xterm 6 dropped its own Alt+arrow → word-move mapping; the pane keeps it.
it("sends Alt+arrow as the word-move keys xterm 5 sent", () => { it("sends Alt+arrow as the word-move keys xterm 5 sent", () => {
expect(altArrowSeq("ArrowLeft", false)).toBe("\x1b[1;5D"); expect(altArrowSeq("ArrowLeft", false)).toBe("\x1b[1;5D");
@@ -262,11 +262,11 @@ function LabelEdit({
function useRunner(load: () => Promise<unknown>) { function useRunner(load: () => Promise<unknown>) {
const [busy, setBusy] = useState<string | null>(null); const [busy, setBusy] = useState<string | null>(null);
const run = useCallback( const run = useCallback(
async (key: string, fn: () => Promise<unknown>, success: string) => { async <T,>(key: string, fn: () => Promise<T>, success: string | ((r: T) => string)) => {
setBusy(key); setBusy(key);
try { try {
await fn(); const r = await fn();
toast.success(success); toast.success(typeof success === "function" ? success(r) : success);
await load(); await load();
} catch (err: unknown) { } catch (err: unknown) {
toast.error(err); toast.error(err);
@@ -399,7 +399,14 @@ function OtherProfiles({
void run( void run(
`switch:${p.name}`, `switch:${p.name}`,
() => api.toolProfiles.activate(tool, p.name), () => api.toolProfiles.activate(tool, p.name),
t("toolProfiles.switchSuccess", { name: p.label, tool: label }) (r) =>
t(
r.following ? "toolProfiles.switchFollowing" : "toolProfiles.switchSuccess",
{
name: p.label,
tool: label,
}
)
) )
} }
className={btnCls} className={btnCls}
@@ -800,9 +807,7 @@ function ProfileUsageLine({
/** Org / seat / plan line shared by the active card and each profile row. */ /** Org / seat / plan line shared by the active card and each profile row. */
function IdentityLine({ identity }: { identity: CcAccountIdentity }) { function IdentityLine({ identity }: { identity: CcAccountIdentity }) {
const parts = [identity.organization, identity.plan || identity.subscriptionType].filter( const parts = [identity.organization, identity.plan || identity.subscriptionType].filter(Boolean);
Boolean
);
if (parts.length === 0) return null; if (parts.length === 0) return null;
return <div className="text-[10px] text-fg-muted mt-0.5">{parts.join(" · ")}</div>; return <div className="text-[10px] text-fg-muted mt-0.5">{parts.join(" · ")}</div>;
} }
+15 -1
View File
@@ -2,7 +2,8 @@
* @file UserEditDialog.tsx * @file UserEditDialog.tsx
* @description The Users page's ONE account editor (admin only): profile * @description The Users page's ONE account editor (admin only): profile
* (display name, email), access (role, active/disabled — neither for your own * (display name, email), access (role, active/disabled — neither for your own
* account), a new password (typed or generated, copyable — someone else's * account — and, for an admin, whether they own the host's Linux account, so
* its sessions outside every workspace are billed to them), a new password (typed or generated, copyable — someone else's
* may be anything and makes them choose their own at next sign-in; your own * may be anything and makes them choose their own at next sign-in; your own
* must pass every rule of `lib/passwordRules.ts`), the workspaces the * must pass every rule of `lib/passwordRules.ts`), the workspaces the
* account owns (remove, add from every workspace), its spend quota (the * account owns (remove, add from every workspace), its spend quota (the
@@ -87,6 +88,7 @@ export function UserEditDialog({
const [email, setEmail] = useState(user.email ?? ""); const [email, setEmail] = useState(user.email ?? "");
const [role, setRole] = useState<Role>(user.role); const [role, setRole] = useState<Role>(user.role);
const [active, setActive] = useState(!user.disabled); const [active, setActive] = useState(!user.disabled);
const [hostAccount, setHostAccount] = useState(Boolean(user.hostAccount));
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
const [owned, setOwned] = useState<OwnedWorkspace[] | null>(null); const [owned, setOwned] = useState<OwnedWorkspace[] | null>(null);
@@ -139,6 +141,7 @@ export function UserEditDialog({
if (norm(email) !== (user.email ?? null)) patch.email = norm(email); if (norm(email) !== (user.email ?? null)) patch.email = norm(email);
if (!self && role !== user.role) patch.role = role; if (!self && role !== user.role) patch.role = role;
if (!self && active === user.disabled) patch.disabled = !active; if (!self && active === user.disabled) patch.disabled = !active;
if (role === "admin" && hostAccount !== Boolean(user.hostAccount)) patch.hostAccount = hostAccount;
if (password) patch.password = password; if (password) patch.password = password;
if (added.length) patch.addWorkspaces = added; if (added.length) patch.addWorkspaces = added;
if (removed.size) patch.removeWorkspaces = [...removed]; if (removed.size) patch.removeWorkspaces = [...removed];
@@ -364,6 +367,17 @@ export function UserEditDialog({
)} )}
</div> </div>
</div> </div>
{role === "admin" && (
<div className="mt-3">
<Checkbox
checked={hostAccount}
onChange={setHostAccount}
label={t("edit.hostAccount")}
labelClassName="text-sm text-fg-secondary"
/>
<p className="ml-6 mt-0.5 text-[11px] text-fg-muted">{t("edit.hostAccountHint")}</p>
</div>
)}
</Section> </Section>
<Section title={t("edit.password")}> <Section title={t("edit.password")}>
+1 -2
View File
@@ -92,6 +92,7 @@
"changes.expandFile": "Expand {{path}}", "changes.expandFile": "Expand {{path}}",
"changes.fileGone": "That file no longer has changes in this range.", "changes.fileGone": "That file no longer has changes in this range.",
"changes.fileTruncated": "Diff cut short — open the file in an editor for the rest.", "changes.fileTruncated": "Diff cut short — open the file in an editor for the rest.",
"changes.lineElided": "… {{size}} more characters on this line",
"changes.commentLine": "Comment on line {{n}} — pasted into this workspace's pane", "changes.commentLine": "Comment on line {{n}} — pasted into this workspace's pane",
"changes.commentOn": "Comment on {{at}} — pasted into the pane you used last; nothing is sent until you press Enter there", "changes.commentOn": "Comment on {{at}} — pasted into the pane you used last; nothing is sent until you press Enter there",
"changes.commentSend": "Paste into pane", "changes.commentSend": "Paste into pane",
@@ -505,8 +506,6 @@
"changes.loadMore": "Load more commits", "changes.loadMore": "Load more commits",
"changes.commentPasted": "Pasted into tab {{tab}} — press Enter there to send it", "changes.commentPasted": "Pasted into tab {{tab}} — press Enter there to send it",
"changes.splitter": "Resize the changes panel", "changes.splitter": "Resize the changes panel",
"tree.archiveLiveTitle": "Archive a live session?",
"tree.archiveLiveBody": "Its tab keeps running; archiving only hides it from this list. Unarchive it from the archived fold.",
"tree.state.awaiting": "waiting for you", "tree.state.awaiting": "waiting for you",
"tree.state.error": "error", "tree.state.error": "error",
"tree.state.live": "live", "tree.state.live": "live",
+1
View File
@@ -1396,6 +1396,7 @@
"noActive": "{{tool}} holds no login on this machine.", "noActive": "{{tool}} holds no login on this machine.",
"noProfiles": "Could not load the profiles.", "noProfiles": "Could not load the profiles.",
"switchSuccess": "Switched {{tool}} to \"{{name}}\". Start a new session to use it.", "switchSuccess": "Switched {{tool}} to \"{{name}}\". Start a new session to use it.",
"switchFollowing": "Switched {{tool}} to \"{{name}}\" — running sessions follow it.",
"deleteConfirmBody": "This removes the saved {{tool}} login. To use it again, sign in from a terminal and save it once more.", "deleteConfirmBody": "This removes the saved {{tool}} login. To use it again, sign in from a terminal and save it once more.",
"loginHint": "Add another {{tool}} login by signing in from a terminal first:", "loginHint": "Add another {{tool}} login by signing in from a terminal first:",
"subtitle": { "subtitle": {
+2
View File
@@ -112,6 +112,8 @@
"status": "Status", "status": "Status",
"active": "Active", "active": "Active",
"statusHint": "A disabled user cannot sign in; their workspaces and history stay.", "statusHint": "A disabled user cannot sign in; their workspaces and history stay.",
"hostAccount": "Owns this server's host account",
"hostAccountHint": "Sessions run as the server's own Linux account outside every workspace (a script's claude -p, a CLI in a plain terminal) count toward this admin's spend. At most one admin; with none marked, the only admin.",
"selfRole": "You cannot change your own role.", "selfRole": "You cannot change your own role.",
"selfDisable": "You cannot disable yourself.", "selfDisable": "You cannot disable yourself.",
"password": "Password", "password": "Password",
+1 -2
View File
@@ -92,6 +92,7 @@
"changes.expandFile": "Mở {{path}}", "changes.expandFile": "Mở {{path}}",
"changes.fileGone": "File này không còn thay đổi nào trong phạm vi đang xem.", "changes.fileGone": "File này không còn thay đổi nào trong phạm vi đang xem.",
"changes.fileTruncated": "Diff bị cắt bớt — mở file trong editor để xem phần còn lại.", "changes.fileTruncated": "Diff bị cắt bớt — mở file trong editor để xem phần còn lại.",
"changes.lineElided": "… còn {{size}} ký tự trên dòng này",
"changes.commentLine": "Comment dòng {{n}} — dán vào pane của workspace này", "changes.commentLine": "Comment dòng {{n}} — dán vào pane của workspace này",
"changes.commentOn": "Comment {{at}} — dán vào pane bạn dùng gần nhất; chưa gửi gì cho tới khi bạn nhấn Enter ở đó", "changes.commentOn": "Comment {{at}} — dán vào pane bạn dùng gần nhất; chưa gửi gì cho tới khi bạn nhấn Enter ở đó",
"changes.commentSend": "Dán vào pane", "changes.commentSend": "Dán vào pane",
@@ -505,8 +506,6 @@
"changes.loadMore": "Tải thêm commit", "changes.loadMore": "Tải thêm commit",
"changes.commentPasted": "Đã dán vào tab {{tab}} — nhấn Enter ở đó để gửi", "changes.commentPasted": "Đã dán vào tab {{tab}} — nhấn Enter ở đó để gửi",
"changes.splitter": "Đổi độ rộng khung thay đổi", "changes.splitter": "Đổi độ rộng khung thay đổi",
"tree.archiveLiveTitle": "Lưu trữ một session đang chạy?",
"tree.archiveLiveBody": "Tab của nó vẫn chạy; lưu trữ chỉ ẩn nó khỏi danh sách này. Bỏ lưu trữ trong mục đã lưu trữ.",
"tree.state.awaiting": "đang chờ bạn", "tree.state.awaiting": "đang chờ bạn",
"tree.state.error": "lỗi", "tree.state.error": "lỗi",
"tree.state.live": "đang chạy", "tree.state.live": "đang chạy",
+1
View File
@@ -1384,6 +1384,7 @@
"noActive": "{{tool}} chưa có đăng nhập nào trên máy này.", "noActive": "{{tool}} chưa có đăng nhập nào trên máy này.",
"noProfiles": "Không tải được danh sách hồ sơ.", "noProfiles": "Không tải được danh sách hồ sơ.",
"switchSuccess": "Đã chuyển {{tool}} sang \"{{name}}\". Mở phiên mới để dùng.", "switchSuccess": "Đã chuyển {{tool}} sang \"{{name}}\". Mở phiên mới để dùng.",
"switchFollowing": "Đã chuyển {{tool}} sang \"{{name}}\" — các phiên đang chạy theo luôn.",
"deleteConfirmBody": "Thao tác này xoá đăng nhập {{tool}} đã lưu. Muốn dùng lại, hãy đăng nhập từ terminal và lưu lại lần nữa.", "deleteConfirmBody": "Thao tác này xoá đăng nhập {{tool}} đã lưu. Muốn dùng lại, hãy đăng nhập từ terminal và lưu lại lần nữa.",
"loginHint": "Thêm đăng nhập {{tool}} khác bằng cách đăng nhập từ terminal trước:", "loginHint": "Thêm đăng nhập {{tool}} khác bằng cách đăng nhập từ terminal trước:",
"subtitle": { "subtitle": {
+2
View File
@@ -112,6 +112,8 @@
"status": "Trạng thái", "status": "Trạng thái",
"active": "Đang hoạt động", "active": "Đang hoạt động",
"statusHint": "Người dùng bị vô hiệu hoá không thể đăng nhập; workspace và lịch sử vẫn được giữ.", "statusHint": "Người dùng bị vô hiệu hoá không thể đăng nhập; workspace và lịch sử vẫn được giữ.",
"hostAccount": "Sở hữu tài khoản host của máy chủ",
"hostAccountHint": "Session chạy bằng tài khoản Linux của máy chủ nhưng ngoài mọi workspace (claude -p của script, CLI trong terminal thường) được tính vào chi phí của admin này. Tối đa một admin; nếu chưa ai được chọn thì là admin duy nhất.",
"selfRole": "Bạn không thể đổi vai trò của chính mình.", "selfRole": "Bạn không thể đổi vai trò của chính mình.",
"selfDisable": "Bạn không thể tự vô hiệu hoá mình.", "selfDisable": "Bạn không thể tự vô hiệu hoá mình.",
"password": "Mật khẩu", "password": "Mật khẩu",
+5 -1
View File
@@ -800,6 +800,8 @@ export type UserPatch = {
addWorkspaces?: number[]; addWorkspaces?: number[];
removeWorkspaces?: number[]; removeWorkspaces?: number[];
signOut?: true; signOut?: true;
/** Owns the host's Linux account: its sessions outside every workspace bill to them (admins only). */
hostAccount?: boolean;
/** Their own spend quota (`default` = the default applies). */ /** Their own spend quota (`default` = the default applies). */
quota?: UserQuotaSetting; quota?: UserQuotaSetting;
}; };
@@ -983,6 +985,8 @@ export type AuthUser = {
createdAt?: string; createdAt?: string;
/** An admin gave this password: the user must choose their own before anything else. */ /** An admin gave this password: the user must choose their own before anything else. */
mustChangePassword?: boolean; mustChangePassword?: boolean;
/** Owns the host's Linux account (server/lib/session-account.js) — absent from an older server. */
hostAccount?: boolean;
}; };
export type AuthState = { mode: "local" } | { mode: "server"; user: AuthUser | null }; export type AuthState = { mode: "local" } | { mode: "server"; user: AuthUser | null };
/** GET /api/me/prefs — the signed-in account's own UI preferences (localStorage keys). */ /** GET /api/me/prefs — the signed-in account's own UI preferences (localStorage keys). */
@@ -1401,7 +1405,7 @@ export const api = {
), ),
/** POST /api/tool-profiles/:tool/activate — the outgoing login is saved back first. */ /** POST /api/tool-profiles/:tool/activate — the outgoing login is saved back first. */
activate: (tool: string, name: string) => activate: (tool: string, name: string) =>
request<{ ok: true; name: string; savedOutgoing: string | null }>( request<{ ok: true; name: string; savedOutgoing: string | null; following?: boolean }>(
`/tool-profiles/${encodeURIComponent(tool)}/activate`, `/tool-profiles/${encodeURIComponent(tool)}/activate`,
{ method: "POST", body: JSON.stringify({ name }) } { method: "POST", body: JSON.stringify({ name }) }
), ),
+2 -1
View File
@@ -6,7 +6,8 @@
* message. Both ask first when any pane holds an AI agent (it may be * message. Both ask first when any pane holds an AI agent (it may be
* mid-turn); a plain shell holds no session, so it neither asks nor sleeps. * mid-turn); a plain shell holds no session, so it neither asks nor sleeps.
* Used by the tab bar (`ConsoleTabs`), the split panes (`Workspace`) and a * Used by the tab bar (`ConsoleTabs`), the split panes (`Workspace`) and a
* session's right-click menu (`SessionMenu`). * session's right-click menu (`SessionMenu`), and a live session's Archive
* (`WorkspaceSessionList`, `WorkspaceHome`) — archiving never leaves a pane running.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn> * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/ */
+3
View File
@@ -2974,6 +2974,9 @@ export interface LaneDiffLine {
/** Line number on the right (after) side; null on a removed line. */ /** Line number on the right (after) side; null on a removed line. */
newLine: number | null; newLine: number | null;
text: string; text: string;
/** Characters cut off the end of a very long row (a base64 blob, a minified
* bundle): `text` holds only its start. Absent on a whole row. */
elided?: number;
} }
/** `GET /api/lanes/:id/git/blame` — Annotate: who last changed each line of /** `GET /api/lanes/:id/git/blame` — Annotate: who last changed each line of
@@ -7316,7 +7316,7 @@ exports[`screen snapshots > Settings — about 1`] = `
<span <span
class="text-status-warning/90" class="text-status-warning/90"
> >
UI build v2.8.39 (rebuild client to match) UI build v2.8.43 (rebuild client to match)
</span> </span>
</p> </p>
</div> </div>
+6 -1
View File
@@ -817,6 +817,11 @@ that file.
- Three caps guard the response: **300** files listed, **200 KB** of patch per - Three caps guard the response: **300** files listed, **200 KB** of patch per
file, **1 MB** of patch in a `full` read. A cap that bit sets `truncated` on file, **1 MB** of patch in a `full` read. A cap that bit sets `truncated` on
the response and on the file it cut. the response and on the file it cut.
- A row longer than **4,000** characters (an inlined base64 image or video, a
minified bundle) comes back cut to its first 1,000 with `elided: <characters
hidden>`, BEFORE the per-file cap is applied, so one huge line no longer eats
the hunks after it. Each file's raw patch is read up to 16 M characters; past
that it is still `truncated`.
- Read-only, no same-origin guard. `available: false` (HTTP **200**) for a cwd - Read-only, no same-origin guard. `available: false` (HTTP **200**) for a cwd
git cannot read; `range=branch` compares against the workspace's base branch, git cannot read; `range=branch` compares against the workspace's base branch,
else the repo's default branch (what `origin/HEAD` names, else `main`/`master`) else the repo's default branch (what `origin/HEAD` names, else `main`/`master`)
@@ -3036,7 +3041,7 @@ POST /api/tool-profiles/:tool/activate Body: { name }
DELETE /api/tool-profiles/:tool Body: { name } DELETE /api/tool-profiles/:tool Body: { name }
``` ```
The login profiles of the AI tools other than Claude Code (`server/lib/tool-profiles.js`; Claude's are `/api/cc-config/profiles` above). Settings → AI tools → **Profiles** shows both, one tab per tool. `:tool` is `codex` (the whole `$CODEX_HOME/auth.json`: a ChatGPT login or an API key) or `omp` (every credential Oh My Pi stores, read with `omp auth export` and written back with `omp auth import -` — secrets on stdin, never argv; import merges, so a switch first empties omp's credential tables in `~/.omp/agent/agent.db`, and a failed import puts the outgoing set back). `antigravity` is listed with `supported: false` and a `reason`: agy keeps its login in the OS keyring, which AgentHub does not touch (`409 EUNSUPPORTED`). `GET /` answers `{tools: [{tool, supported, reason?, items, active, error?}]}`, each item `{name, label, savedAt, active, kind, email, detail}` — never a token; one tool's failure (omp not installed) is that tool's `error`, not the request's. Profiles are stored one file each under `<CLAUDE_HOME>/agenthub-profiles/<tool>/` (0700/0600) with cc-profiles' rules: one login is one profile (`409 EDUPLICATE`), a switch re-saves the outgoing login into its own profile first and refuses one never saved (`409 EUNSAVED`), the live login's profile is not deletable (`409 EACTIVE`), `409 ENOAUTH` when the tool holds no login, `409 ENOTINSTALLED`, `502 EOMP` when omp's own command fails. Writes are same-origin only; in server mode `/tool-profiles` is in `ADMIN_WRITE_PREFIXES` and `STAFF_ONLY_PREFIXES` — it changes the HOST's own logins. The login profiles of the AI tools other than Claude Code (`server/lib/tool-profiles.js`; Claude's are `/api/cc-config/profiles` above). Settings → AI tools → **Profiles** shows both, one tab per tool. `:tool` is `codex` (the whole `$CODEX_HOME/auth.json`: a ChatGPT login or an API key) or `omp` (every credential Oh My Pi stores, read with `omp auth export` and written back with `omp auth import -` — secrets on stdin, never argv; import merges, so a switch first empties omp's credential tables in `~/.omp/agent/agent.db`, and a failed import puts the outgoing set back). `antigravity` is listed with `supported: false` and a `reason`: agy keeps its login in the OS keyring, which AgentHub does not touch (`409 EUNSUPPORTED`). `GET /` answers `{tools: [{tool, supported, reason?, items, active, error?}]}`, each item `{name, label, savedAt, active, kind, email, detail}` — never a token; one tool's failure (omp not installed) is that tool's `error`, not the request's. Profiles are stored one file each under `<CLAUDE_HOME>/agenthub-profiles/<tool>/` (0700/0600) with cc-profiles' rules: one login is one profile (`409 EDUPLICATE`), a switch re-saves the outgoing login into its own profile first and refuses one never saved (`409 EUNSAVED`), and for Codex then runs `codex app-server daemon restart` when that CODEX_HOME's shared daemon is running (its `app-server-control.sock` resolves) — the daemon holds the login it started with, so without it neither an open nor a NEW Codex session saw the switch; open TUIs reconnect on their own — answering `following: true` (the activate response's extra field; `false` when no daemon ran), the live login's profile is not deletable (`409 EACTIVE`), `409 ENOAUTH` when the tool holds no login, `409 ENOTINSTALLED`, `502 EOMP` when omp's own command fails. Writes are same-origin only; in server mode `/tool-profiles` is in `ADMIN_WRITE_PREFIXES` and `STAFF_ONLY_PREFIXES` — it changes the HOST's own logins.
### AI Proxy profiles ### AI Proxy profiles
+1 -1
View File
File diff suppressed because one or more lines are too long
+11 -1
View File
@@ -732,7 +732,17 @@ An admin limits what each user may spend, in dollars, on the Users page
every account change; each one re-applies at once (`sweep`). every account change; each one re-applies at once (`sweep`).
Spend is the users page's own figure: the tokens of the sessions the user Spend is the users page's own figure: the tokens of the sessions the user
OWNS through a workspace, priced by `calculateCost`. The Users table shows OWNS, priced by `calculateCost`. A session is owned through its workspace;
one no workspace owns — a script's `claude -p` in a temp folder, a CLI in a
plain terminal — is owned by the user of the Linux account it ran as
(`sessions.account`, `server/lib/session-account.js`: the hook's peer uid,
else the session's folder): a member's account bills to that member, the
host's to the admin ticked **Owns this server's host account** in Edit user
(`PATCH /api/users/:id` `{hostAccount: true}`, at most one), or with none
ticked to the only admin. The same rule feeds the status bar's **Today**.
A process that drops the pane's environment also drops
`AGENTHUB_QUOTA_FILE`, so its prompts are not refused by the hook — but its
spend still counts, and stops the user's next session. The Users table shows
each user's spend against their quota; the user sees their own as **Quota N%** each user's spend against their quota; the user sees their own as **Quota N%**
in the status bar (`GET /api/me/quota`). in the status bar (`GET /api/me/quota`).
+7 -1
View File
@@ -3,7 +3,7 @@
openapi: 3.0.3 openapi: 3.0.3
info: info:
title: Agent Dashboard for Claude Code API title: Agent Dashboard for Claude Code API
version: 2.8.30 version: 2.8.40
description: HTTP API for real-time Claude Code session monitoring, agent lifecycle tracking, analytics, pricing, hooks ingestion, and workflow intelligence. description: HTTP API for real-time Claude Code session monitoring, agent lifecycle tracking, analytics, pricing, hooks ingestion, and workflow intelligence.
contact: contact:
name: Nguyễn Ngọc Trí Vĩ name: Nguyễn Ngọc Trí Vĩ
@@ -15410,6 +15410,9 @@ paths:
type: boolean type: boolean
enum: enum:
- true - true
hostAccount:
type: boolean
description: 'An admin owns the host''s Linux account: its sessions outside every workspace are billed to them (at most one; EBADROLE for a non-admin)'
quota: quota:
$ref: '#/components/schemas/UserQuotaSetting' $ref: '#/components/schemas/UserQuotaSetting'
currentPassword: currentPassword:
@@ -19932,6 +19935,9 @@ paths:
items: items:
type: string type: string
description: Files the switch wrote. description: Files the switch wrote.
following:
type: boolean
description: 'Codex: a running app-server daemon was restarted, so open and new sessions use the new login.'
'400': '400':
description: '`EBADNAME` / `EBADLABEL`.' description: '`EBADNAME` / `EBADLABEL`.'
content: content:
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "agenthub", "name": "agenthub",
"version": "2.8.39", "version": "2.8.43",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "agenthub", "name": "agenthub",
"version": "2.8.39", "version": "2.8.43",
"hasInstallScript": true, "hasInstallScript": true,
"license": "UNLICENSED", "license": "UNLICENSED",
"dependencies": { "dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "agenthub", "name": "agenthub",
"version": "2.8.39", "version": "2.8.43",
"bin": { "bin": {
"agenthub": "bin/agenthub.js" "agenthub": "bin/agenthub.js"
}, },
@@ -473,6 +473,55 @@ describe("reads larger than the git() buffer report truncated, not missing", ()
}); });
}); });
describe("a page with megabyte-long lines (inlined base64) keeps every hunk", () => {
const blob = (c) => c.repeat(3 * 1024 * 1024); // one 3 MB line
it("cuts the long rows down and still shows the hunks after them", async () => {
const body = (mid, end) =>
[
"<h1>title</h1>",
...Array.from({ length: 40 }, (_, i) => `row ${i}`),
mid,
...Array.from({ length: 40 }, (_, i) => `tail ${i}`),
end,
"",
].join("\n");
write(LANE, "deck.html", body(`<video src="${blob("A")}">`, "<p>old end</p>"));
g(LANE, "add", "-A");
g(LANE, "commit", "-qm", "deck");
write(
LANE,
"deck.html",
body(`<video src="${blob("B")}">`, "<p>new end</p>").replace(
"<h1>title</h1>",
"<h1>new title</h1>"
)
);
const out = await collectChanges(LANE, { range: "work", file: "deck.html" });
const f = out.files[0];
assert.equal(f.truncated, undefined);
assert.equal(out.truncated, false);
const lines = f.hunks.flatMap((h) => h.lines);
const long = lines.filter((l) => l.elided);
assert.equal(long.length, 2);
for (const l of long) {
assert.ok(l.text.length < 2000);
assert.equal(l.text.length + l.elided, '<video src="'.length + 3 * 1024 * 1024 + 2);
}
// The change AFTER the 3 MB rows survives: the cap no longer eats it.
assert.ok(lines.some((l) => l.type === "add" && l.text === "<p>new end</p>"));
assert.ok(lines.some((l) => l.type === "add" && l.text === "<h1>new title</h1>"));
});
it("leaves ordinary rows whole", async () => {
write(LANE, "a.txt", "one\nTWO\n");
const out = await collectChanges(LANE, { range: "work", file: "a.txt" });
const lines = out.files[0].hunks.flatMap((h) => h.lines);
assert.ok(lines.every((l) => l.elided === undefined));
assert.ok(lines.some((l) => l.text === "TWO"));
});
});
describe("loop 2: secret file at any depth, symlinked .claude", () => { describe("loop 2: secret file at any depth, symlinked .claude", () => {
const KEY = '{"env":{"ANTHROPIC_API_KEY":"sk-deep"}}'; const KEY = '{"env":{"ANTHROPIC_API_KEY":"sk-deep"}}';
+11 -8
View File
@@ -203,18 +203,21 @@ describe("collectChanges — work range", () => {
}); });
}); });
it("still returns content when a huge file has no newline to cut on", async () => { it("still returns content when a huge file is one line", async () => {
// A minified bundle is ONE line. Cutting the patch at the last newline // A minified bundle is ONE line. It used to be cut at the per-file cap
// inside the cap would land at index -1 here and hand back an empty // (and, cut at the last newline, could come back empty — a file changed
// patch — a file changed by a megabyte rendering as "no textual change". // by a megabyte rendering as "no textual change"); now the row itself is
// cut down and says how much it hides, and nothing is truncated.
const { MAX_FILE_BYTES } = require("../lib/git-diff"); const { MAX_FILE_BYTES } = require("../lib/git-diff");
write(LANE, "min.js", `${"x".repeat(MAX_FILE_BYTES + 5000)}\n`); write(LANE, "min.js", `${"x".repeat(MAX_FILE_BYTES + 5000)}\n`);
const out = await collectChanges(LANE, { range: "work", file: "min.js" }); const out = await collectChanges(LANE, { range: "work", file: "min.js" });
const entry = out.files[0]; const entry = out.files[0];
assert.equal(entry.truncated, true); assert.equal(entry.truncated, undefined);
assert.equal(out.truncated, true); assert.equal(out.truncated, false);
assert.ok(entry.hunks.length > 0, "a truncated patch still carries its hunk"); assert.ok(entry.hunks.length > 0, "the patch still carries its hunk");
assert.ok(entry.hunks[0].lines[0].text.startsWith("xxx")); const row = entry.hunks[0].lines.find((l) => l.type === "add");
assert.ok(row.text.startsWith("xxx"));
assert.equal(row.text.length + row.elided, MAX_FILE_BYTES + 5000);
}); });
it("reports a binary file without trying to diff it", async () => { it("reports a binary file without trying to diff it", async () => {
+110
View File
@@ -0,0 +1,110 @@
/**
* @file session-account.test.js
* @description A session no workspace owns is billed to the user of the Linux
* account it ran as (server/lib/session-account.js): a member's account to
* that member, the host's to the admin marked as its owner (or the only
* admin). A workspace's ownership always wins, a pushed session never gets an
* account, and the hook peer is stamped once.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
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(), "session-account-test-"));
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
const { db } = require("../db");
const sa = require("../lib/session-account");
const scope = require("../lib/scope");
const { OWNED_SESSIONS } = require("../routes/users");
const own = (id) => {
const w = scope.ownSessionWhere({ user: { id } });
return db
.prepare(`SELECT id FROM sessions WHERE ${w.where} ORDER BY id`)
.all(...w.params)
.map((r) => r.id);
};
before(() => {
const user = db.prepare(
"INSERT INTO users (id, username, role, password_hash) VALUES (?, ?, ?, 'x')"
);
user.run(1, "boss", "admin");
user.run(2, "dev", "developer");
db.prepare("INSERT INTO lanes (id, cwd, tool, owner_id) VALUES (1, '/w/dev', 'claude', 2)").run();
const s = db.prepare(
"INSERT INTO sessions (id, name, status, cwd, lane_id, account, source) VALUES (?, ?, 'active', '/w', ?, ?, ?)"
);
s.run("host-free", "eval", null, "", "local");
s.run("dev-free", "eval", null, "dev", "local");
s.run("host-in-dev-lane", "lane", 1, "", "local");
s.run("unknown", "old", null, null, "local");
s.run("gone-member", "x", null, "ghost", "local");
});
after(() => fs.rmSync(TMP, { recursive: true, force: true }));
describe("session-account", () => {
it("bills a member's account to that member and the host's to the only admin", () => {
assert.equal(sa.hostOwner(), 1);
assert.deepEqual(own(1), ["host-free"]);
assert.deepEqual(own(2), ["dev-free", "host-in-dev-lane"]);
});
it("lets the workspace's owner win over the account", () => {
assert.ok(!own(1).includes("host-in-dev-lane"));
});
it("bills nobody for the host when two admins and none is marked, then the marked one", () => {
db.prepare(
"INSERT INTO users (id, username, role, password_hash) VALUES (3, 'boss2', 'admin', 'x')"
).run();
assert.equal(sa.hostOwner(), null);
assert.deepEqual(own(1), []);
sa.setHostOwner(3);
assert.equal(sa.hostOwner(), 3);
assert.deepEqual(own(3), ["host-free"]);
sa.setHostOwner(1);
assert.deepEqual(own(3), []);
assert.deepEqual(own(1), ["host-free"]);
});
it("lists account-billed sessions in OWNED_SESSIONS (Users page, spend quota)", () => {
const rows = db.prepare(OWNED_SESSIONS).all();
const by = Object.fromEntries(rows.map((r) => [r.session_id, r.uid]));
assert.equal(by["host-free"], 1);
assert.equal(by["dev-free"], 2);
assert.equal(by["host-in-dev-lane"], 2);
assert.equal(by.unknown, undefined);
assert.equal(by["gone-member"], undefined);
});
it("stamps the hook peer once and never a pushed session", () => {
db.prepare("INSERT INTO sessions (id, name, status, cwd) VALUES ('fresh', 'f', 'active', '/w')").run();
db.prepare("INSERT INTO sessions (id, name, status, cwd) VALUES ('pushed', 'p', 'active', '/w')").run();
sa.stampFromPeer("fresh", { kind: "member", name: "dev", home: "/srv/u/dev" });
sa.stampFromPeer("fresh", { kind: "host" });
sa.stampFromPeer("pushed", { kind: "host" }, "push:laptop");
sa.stampFromPeer("pushed", { kind: "refused" });
const acct = (id) => db.prepare("SELECT account FROM sessions WHERE id = ?").get(id).account;
assert.equal(acct("fresh"), "dev");
assert.equal(acct("pushed"), null);
});
it("gives a folder's account to sessions no hook stamped, skipping pushed ones", () => {
db.prepare(
"INSERT INTO sessions (id, name, status, cwd, source) VALUES ('remote', 'r', 'active', '/w', 'push:laptop')"
).run();
sa.stampFromFolders();
const acct = (id) => db.prepare("SELECT account FROM sessions WHERE id = ?").get(id).account;
// Outside S3 every folder is the host's.
assert.equal(acct("unknown"), "");
assert.equal(acct("pushed"), "");
assert.equal(acct("remote"), null);
});
});
+27
View File
@@ -97,6 +97,33 @@ describe("tool-profiles: codex", () => {
); );
}); });
it("restarts a running Codex daemon so open sessions follow; none running, nothing to restart", async () => {
login("acct-a", "alice@example.com");
profiles.saveProfile("codex");
login("acct-b", "bob@example.com");
profiles.saveProfile("codex");
assert.equal(profiles.activateProfile("codex", "alice").following, false);
const log = path.join(ROOT, "codex-argv.log");
const bin = path.join(ROOT, "fake-codex.sh");
fs.writeFileSync(bin, `#!/bin/sh\necho "$CODEX_HOME $*" >> '${log}'\n`, { mode: 0o755 });
const sock = path.join(process.env.CODEX_HOME, "app-server-control", "app-server-control.sock");
fs.mkdirSync(path.dirname(sock), { recursive: true });
fs.writeFileSync(sock, "");
process.env.AGENTHUB_CODEX_BIN = bin;
try {
assert.equal(profiles.activateProfile("codex", "bob").following, true);
for (let i = 0; i < 50 && !fs.existsSync(log); i++)
await new Promise((r) => setTimeout(r, 20));
assert.equal(
fs.readFileSync(log, "utf8").trim(),
`${process.env.CODEX_HOME} app-server daemon restart`
);
} finally {
delete process.env.AGENTHUB_CODEX_BIN;
}
});
it("refuses to switch away from an unsaved login", () => { it("refuses to switch away from an unsaved login", () => {
login("acct-a", "alice@example.com"); login("acct-a", "alice@example.com");
profiles.saveProfile("codex"); profiles.saveProfile("codex");
+6
View File
@@ -93,6 +93,12 @@ describe("GET /api/sessions/:id/stats tool totals", () => {
assert.equal(body.tool_call_count, 2); assert.equal(body.tool_call_count, 2);
assert.equal(body.distinct_tool_count, 1); assert.equal(body.distinct_tool_count, 1);
}); });
it("counts each call once in tools_used, not its Pre and Post events", async () => {
const one = (await get("/api/sessions/ua-1/stats")).body.tools_used;
assert.ok(one.every((t) => t.count === 1));
const two = (await get("/api/sessions/ua-2/stats")).body.tools_used;
assert.deepEqual(two, [{ tool_name: "Read", count: 2 }]);
});
}); });
describe("GET /api/events labels", () => { describe("GET /api/events labels", () => {
+19
View File
@@ -1445,6 +1445,16 @@ try {
db.prepare("ALTER TABLE sessions ADD COLUMN pinned_at TEXT").run(); db.prepare("ALTER TABLE sessions ADD COLUMN pinned_at TEXT").run();
} }
// Migrate: add `account` to sessions — the Linux account a session ran as
// (server/lib/session-account.js): "" = the host's, else the S3 member's name;
// NULL = not known yet. A session no workspace owns is billed to the user of
// that account (server/lib/scope.js), so a script's `claude -p` still counts.
try {
db.prepare("SELECT account FROM sessions LIMIT 1").get();
} catch {
db.prepare("ALTER TABLE sessions ADD COLUMN account TEXT").run();
}
// How far each Codex rollout journal has been read (server/lib/codex-usage.js). // How far each Codex rollout journal has been read (server/lib/codex-usage.js).
// In the DB, not a side file, so a session the user deleted stays deleted // In the DB, not a side file, so a session the user deleted stays deleted
// until Codex writes to its journal again, while a wiped DB re-imports all. // until Codex writes to its journal again, while a wiped DB re-imports all.
@@ -1952,6 +1962,9 @@ for (const col of ["display_name", "email", "avatar_at", "avatar_link"]) {
for (const [col, type] of [ for (const [col, type] of [
["last_login_at", "TEXT"], ["last_login_at", "TEXT"],
["must_change_password", "INTEGER NOT NULL DEFAULT 0"], ["must_change_password", "INTEGER NOT NULL DEFAULT 0"],
// The admin who owns the HOST's Linux account: its sessions outside every
// workspace are billed to them (server/lib/session-account.js). At most one.
["host_account", "INTEGER NOT NULL DEFAULT 0"],
]) { ]) {
try { try {
db.prepare(`SELECT ${col} FROM users LIMIT 1`).get(); db.prepare(`SELECT ${col} FROM users LIMIT 1`).get();
@@ -2547,10 +2560,16 @@ const stmts = {
FROM events FROM events
WHERE session_id = ? AND tool_name IS NOT NULL WHERE session_id = ? AND tool_name IS NOT NULL
`), `),
// One row per call, like sessionToolCallTotals: a call is its PreToolUse,
// or its PostToolUse in a session that has none (imported). Counting every
// event with a tool_name counted each call twice.
sessionToolUsageCounts: db.prepare(` sessionToolUsageCounts: db.prepare(`
SELECT tool_name, COUNT(*) as count SELECT tool_name, COUNT(*) as count
FROM events FROM events
WHERE session_id = ? AND tool_name IS NOT NULL WHERE session_id = ? AND tool_name IS NOT NULL
AND event_type = CASE
WHEN EXISTS (SELECT 1 FROM events WHERE session_id = ? AND event_type = 'PreToolUse')
THEN 'PreToolUse' ELSE 'PostToolUse' END
GROUP BY tool_name GROUP BY tool_name
ORDER BY count DESC ORDER BY count DESC
LIMIT 15 LIMIT 15
+4 -1
View File
@@ -873,7 +873,9 @@ function startBackgroundServices() {
if (poolTimer.unref) poolTimer.unref(); if (poolTimer.unref) poolTimer.unref();
} }
// Server mode's spend quotas (user-quota.js): who is over theirs now, the // Server mode's spend quotas (user-quota.js): who is over theirs now, the
// flag files their hooks read, the panes no hook can stop. Off with // flag files their hooks read, the panes no hook can stop — after giving
// the sessions no hook made their Linux account (session-account.js), so a
// poller's session is billed to someone too. Off with
// DASHBOARD_QUOTA_SWEEP_MS=0; a no-op in local mode. // DASHBOARD_QUOTA_SWEEP_MS=0; a no-op in local mode.
const QUOTA_SWEEP_MS = Number(process.env.DASHBOARD_QUOTA_SWEEP_MS ?? 60_000); const QUOTA_SWEEP_MS = Number(process.env.DASHBOARD_QUOTA_SWEEP_MS ?? 60_000);
if ( if (
@@ -883,6 +885,7 @@ function startBackgroundServices() {
) { ) {
const quotaSweep = () => { const quotaSweep = () => {
try { try {
require("./lib/session-account").stampFromFolders();
require("./lib/user-quota").sweep(); require("./lib/user-quota").sweep();
} catch (err) { } catch (err) {
console.warn("user quota sweep failed:", err.message); console.warn("user quota sweep failed:", err.message);
+7 -1
View File
@@ -213,6 +213,9 @@ const publicUser = (u) =>
createdAt: u.created_at ?? null, createdAt: u.created_at ?? null,
// An admin gave this password: the account must pick its own first. // An admin gave this password: the account must pick its own first.
mustChangePassword: Boolean(u.must_change_password), mustChangePassword: Boolean(u.must_change_password),
// Owns the host's Linux account: its sessions outside every workspace
// are billed to this admin (server/lib/session-account.js).
hostAccount: Boolean(u.host_account),
}; };
const MAX_DISPLAY_NAME = 100; const MAX_DISPLAY_NAME = 100;
@@ -415,7 +418,10 @@ function setRole(username, role) {
if (!ROLES.includes(role)) throw err("EBADROLE", `role: one of ${ROLES.join(", ")}`); if (!ROLES.includes(role)) throw err("EBADROLE", `role: one of ${ROLES.join(", ")}`);
const u = requireUser(username); const u = requireUser(username);
if (role !== "admin") assertKeepsAdmin(u, "demoted"); if (role !== "admin") assertKeepsAdmin(u, "demoted");
sql("UPDATE users SET role = ? WHERE id = ?").run(role, u.id); // Only an admin owns the host account (session-account.js): a demotion drops the mark.
sql(
"UPDATE users SET role = ?, host_account = CASE WHEN ? = 'admin' THEN host_account ELSE 0 END WHERE id = ?"
).run(role, role, u.id);
if (role !== "admin") stopHostPanes(u.id); if (role !== "admin") stopHostPanes(u.id);
} }
+55 -5
View File
@@ -61,6 +61,19 @@ async function readCapped(dir, args, capChars) {
const MAX_FILES = 300; const MAX_FILES = 300;
/** Most patch bytes read for ONE file before its hunks are cut short. */ /** Most patch bytes read for ONE file before its hunks are cut short. */
const MAX_FILE_BYTES = 200_000; const MAX_FILE_BYTES = 200_000;
/** Characters of one file's raw patch read before its long lines are cut down
* (`elideLongLines`) and `MAX_FILE_BYTES` applied. A page that embeds a video
* or a font as base64 is a few lines of megabytes each: read at
* `MAX_FILE_BYTES`, its first such line used the whole budget and every hunk
* after it was lost. Past this the patch is still truncated. */
const MAX_PATCH_READ_CHARS = 16 * 1024 * 1024;
/** A diff row longer than this is shown cut down to `LONG_LINE_KEEP`
* characters, with how many it hides (`elided` on the parsed line). */
const LONG_LINE_CHARS = 4000;
const LONG_LINE_KEEP = 1000;
/** Ends a cut-down row in the raw patch: a private-use character, then the
* count of characters hidden. `parseUnifiedDiff` turns it into `elided`. */
const ELIDED_MARK = "\uE000";
/** Most patch bytes across all files in a `full` (review-mode) response. */ /** Most patch bytes across all files in a `full` (review-mode) response. */
const MAX_TOTAL_BYTES = 1_000_000; const MAX_TOTAL_BYTES = 1_000_000;
/** An untracked file larger than this is reported without content. */ /** An untracked file larger than this is reported without content. */
@@ -259,15 +272,23 @@ function parseUnifiedDiff(patch) {
// row of its own and carries no line number. // row of its own and carries no line number.
if (raw.startsWith("\\")) continue; if (raw.startsWith("\\")) continue;
const marker = raw[0]; const marker = raw[0];
const text = raw.slice(1); let text = raw.slice(1);
// A row `elideLongLines` cut down: the text it kept, and how much it hid.
let elided = 0;
const cut = text.lastIndexOf(ELIDED_MARK);
if (cut >= 0 && /^\d+$/.test(text.slice(cut + 1))) {
elided = Number(text.slice(cut + 1));
text = text.slice(0, cut);
}
const extra = elided ? { elided } : {};
if (marker === "+") { if (marker === "+") {
current.lines.push({ type: "add", oldLine: null, newLine: newLine++, text }); current.lines.push({ type: "add", oldLine: null, newLine: newLine++, text, ...extra });
} else if (marker === "-") { } else if (marker === "-") {
current.lines.push({ type: "del", oldLine: oldLine++, newLine: null, text }); current.lines.push({ type: "del", oldLine: oldLine++, newLine: null, text, ...extra });
} else if (marker === " ") { } else if (marker === " ") {
// An unchanged EMPTY line is " " — one space, no text. A truly empty // An unchanged EMPTY line is " " — one space, no text. A truly empty
// string is the tail of the final "\n", not a row. // string is the tail of the final "\n", not a row.
current.lines.push({ type: "ctx", oldLine: oldLine++, newLine: newLine++, text }); current.lines.push({ type: "ctx", oldLine: oldLine++, newLine: newLine++, text, ...extra });
} }
} }
return hunks; return hunks;
@@ -532,7 +553,27 @@ async function patchFor(dir, { range, baseRef, sha, file, untracked, context })
: range === "branch" : range === "branch"
? ["diff", u, `${baseRef}...HEAD`, "--", file] ? ["diff", u, `${baseRef}...HEAD`, "--", file]
: ["diff", u, "HEAD", "--", file]; : ["diff", u, "HEAD", "--", file];
return readCapped(dir, [...args, "--no-color", "--no-ext-diff"], MAX_FILE_BYTES).catch(() => ""); return readCapped(dir, [...args, "--no-color", "--no-ext-diff"], MAX_PATCH_READ_CHARS).catch(
() => ""
);
}
/**
* The patch with every row longer than `LONG_LINE_CHARS` cut down to its first
* `LONG_LINE_KEEP` characters plus `ELIDED_MARK` and the count it hides. Only
* what the panel SHOWS is cut: the hunk actions (`hunkPatch`, `selectionPatch`)
* re-read git's own diff and match hunks by header, which this never changes.
*/
function elideLongLines(patch) {
if (patch.length <= LONG_LINE_CHARS) return patch;
return patch
.split("\n")
.map((row) =>
row.length > LONG_LINE_CHARS && !row.startsWith("@@")
? `${row.slice(0, LONG_LINE_KEEP + 1)}${ELIDED_MARK}${row.length - LONG_LINE_KEEP - 1}`
: row
)
.join("\n");
} }
/** /**
@@ -648,7 +689,15 @@ async function collectChanges(
untracked: entry.untracked, untracked: entry.untracked,
context: ctx, context: ctx,
}); });
// A read that hit its own ceiling came back partial: still cut short,
// however small eliding its long rows makes it.
const partial = patch.length >= MAX_PATCH_READ_CHARS;
patch = elideLongLines(patch);
const cap = Math.min(MAX_FILE_BYTES, budget); const cap = Math.min(MAX_FILE_BYTES, budget);
if (partial) {
entry.truncated = true;
truncated = true;
}
if (patch.length > cap) { if (patch.length > cap) {
// Cut on a line boundary so the last row is a whole line — but only // Cut on a line boundary so the last row is a whole line — but only
// when that boundary is near the cap. A minified file is one enormous // when that boundary is near the cap. A minified file is one enormous
@@ -1334,6 +1383,7 @@ async function plainTreeAll(dir) {
} }
module.exports = { module.exports = {
elideLongLines,
collectChanges, collectChanges,
CONFLICT_CODES, CONFLICT_CODES,
contentSha, contentSha,
+12 -6
View File
@@ -5,7 +5,9 @@
* *
* A workspace (`lanes.owner_id`) belongs to the user who created it. A session * A workspace (`lanes.owner_id`) belongs to the user who created it. A session
* belongs to its workspace: `sessions.lane_id`, or a `dashboard_runs` row of * belongs to its workspace: `sessions.lane_id`, or a `dashboard_runs` row of
* that workspace holding it (a session not stamped yet). Agents, events and * that workspace holding it (a session not stamped yet). A session no workspace owns
* belongs to the user of the Linux account it ran as (`sessions.account`,
* server/lib/session-account.js) — a script's `claude -p` is its runner's. Agents, events and
* token rows belong to their session. A developer sees and changes only what * token rows belong to their session. A developer sees and changes only what
* their own workspaces own; an analyst SEES everything but changes only their * their own workspaces own; an analyst SEES everything but changes only their
* own (every non-GET request is scoped like a developer's); an admin sees and * own (every non-GET request is scoped like a developer's); an admin sees and
@@ -17,6 +19,7 @@
*/ */
const sql = (q) => require("../db").db.prepare(q); const sql = (q) => require("../db").db.prepare(q);
const { ACCOUNT_OWNED } = require("./session-account");
/** The user whose rows this request is limited to, or null for "all". */ /** The user whose rows this request is limited to, or null for "all". */
function memberId(req) { function memberId(req) {
@@ -53,8 +56,9 @@ function ownedSessionWhere(uid, col, laneCol) {
return { return {
where: `(${laneCol} IN (SELECT id FROM lanes WHERE owner_id = ?) where: `(${laneCol} IN (SELECT id FROM lanes WHERE owner_id = ?)
OR ${col} IN (SELECT r.session_id FROM dashboard_runs r JOIN lanes l ON l.id = r.lane_id OR ${col} IN (SELECT r.session_id FROM dashboard_runs r JOIN lanes l ON l.id = r.lane_id
WHERE l.owner_id = ? AND r.session_id IS NOT NULL))`, WHERE l.owner_id = ? AND r.session_id IS NOT NULL)
params: [uid, uid], OR ${col} IN (SELECT session_id FROM (${ACCOUNT_OWNED}) WHERE uid = ?))`,
params: [uid, uid, uid],
}; };
} }
@@ -264,14 +268,16 @@ function ownerStillFits(laneId) {
const STAFF = { staffOnly: true }; const STAFF = { staffOnly: true };
/** Owner ids of the workspaces that own a session (stamped or held by a run). */ /** Owner ids of a session: its workspace's (stamped or held by a run), else its account's user. */
function sessionOwners(sessionId) { function sessionOwners(sessionId) {
if (!sessionId) return new Set(); if (!sessionId) return new Set();
const rows = sql( const rows = sql(
`SELECT l.owner_id FROM sessions s JOIN lanes l ON l.id = s.lane_id WHERE s.id = ? `SELECT l.owner_id FROM sessions s JOIN lanes l ON l.id = s.lane_id WHERE s.id = ?
UNION UNION
SELECT l.owner_id FROM dashboard_runs r JOIN lanes l ON l.id = r.lane_id WHERE r.session_id = ?` SELECT l.owner_id FROM dashboard_runs r JOIN lanes l ON l.id = r.lane_id WHERE r.session_id = ?
).all(String(sessionId), String(sessionId)); UNION
SELECT uid FROM (${ACCOUNT_OWNED}) WHERE session_id = ?`
).all(String(sessionId), String(sessionId), String(sessionId));
return new Set(rows.map((r) => r.owner_id).filter((x) => x != null)); return new Set(rows.map((r) => r.owner_id).filter((x) => x != null));
} }
+113
View File
@@ -0,0 +1,113 @@
/**
* @file session-account.js
* @description Which Linux account a session ran as (`sessions.account`: ""
* = the host's, else an S3 member's name), and whose bill a session no
* workspace owns goes on. A workspace owns a session through `lane_id` or a
* `dashboard_runs` row (server/lib/scope.js); a session started OUTSIDE every
* workspace — a script's `claude -p`, a CLI typed in a plain terminal — has
* neither, and without this its spend counted for nobody: not in anyone's
* Today, the Users page or a spend quota.
*
* The account is the hook's PEER (server/lib/hook-auth.js: the uid of the
* loopback connection, never the body) when a hook creates the session; a
* session no hook made (the Codex/Antigravity/Cursor/omp pollers, history)
* gets it from its folder, the S3 rule `accountForPath` already applies to
* every pane. A pushed session (another machine's) never gets one. A member
* account bills to the user of that name; the host account to the admin an
* admin marked as its owner (`users.host_account`), or — with no mark — the
* only enabled admin, if there is exactly one.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const sql = (q) => require("../db").db.prepare(q);
// The user the host account's sessions bill to (null: nobody marked and not exactly one admin).
const HOST_OWNER = `COALESCE(
(SELECT MIN(id) FROM users WHERE host_account = 1 AND role = 'admin'),
(SELECT MIN(id) FROM users WHERE role = 'admin' AND disabled = 0 HAVING COUNT(*) = 1))`;
// A session no workspace owns: neither stamped onto an owned lane nor held by
// a run of one — exactly the complement of scope.js's ownership.
const UNOWNED = `(s.lane_id IS NULL OR s.lane_id NOT IN (SELECT id FROM lanes WHERE owner_id IS NOT NULL))
AND s.id NOT IN (SELECT r.session_id FROM dashboard_runs r JOIN lanes l ON l.id = r.lane_id
WHERE r.session_id IS NOT NULL AND l.owner_id IS NOT NULL)`;
/** `session_id` → `uid` for every session billed by its account (no workspace owns it). */
const ACCOUNT_OWNED = `
SELECT s.id AS session_id, u.id AS uid
FROM sessions s JOIN users u
ON u.id = CASE WHEN s.account = '' THEN ${HOST_OWNER}
ELSE (SELECT id FROM users WHERE username = s.account) END
WHERE s.account IS NOT NULL AND ${UNOWNED}`;
/** The account a hook's peer names: "" for the host, a member's name, null otherwise. */
function accountOfPeer(peer) {
if (!peer || peer.kind === "host") return "";
if (peer.kind === "member" && peer.name) return peer.name;
return null;
}
/** Record a hook-created session's account — first writer wins, a pushed hook never writes. */
function stampFromPeer(sessionId, peer, pushSource = null) {
if (pushSource || !sessionId) return;
const account = accountOfPeer(peer);
if (account == null) return;
sql("UPDATE sessions SET account = ? WHERE id = ? AND account IS NULL").run(account, sessionId);
}
/** The account a folder runs as: "" outside the users root, a member's name inside it. */
function accountOfPath(cwd) {
const linux = require("./linux-accounts");
const conf = linux.s3Enabled() ? linux.serverConf() : null;
const root = conf?.usersRoot;
const path = require("path");
if (!root || typeof cwd !== "string" || !path.isAbsolute(cwd)) return "";
const rel = path.relative(root, path.resolve(cwd));
if (rel.startsWith("..") || path.isAbsolute(rel)) return "";
// The users root itself is nobody's.
return rel.split(path.sep)[0] || null;
}
/**
* Give every local session still without an account the one its folder runs
* as (sessions the pollers made, and history from before the column). Cheap
* once caught up: only `account IS NULL` rows are read.
*/
function stampFromFolders() {
const rows = sql(
"SELECT id, cwd FROM sessions WHERE account IS NULL AND source NOT LIKE 'push:%' LIMIT 5000"
).all();
if (!rows.length) return 0;
const set = sql("UPDATE sessions SET account = ? WHERE id = ? AND account IS NULL");
let n = 0;
require("../db").db.transaction(() => {
for (const r of rows) {
const account = accountOfPath(r.cwd);
if (account != null) n += set.run(account, r.id).changes;
}
})();
return n;
}
/** Mark `userId` (an admin) as the host account's owner, or clear the mark with null. */
function setHostOwner(userId) {
require("../db").db.transaction(() => {
sql("UPDATE users SET host_account = 0 WHERE host_account = 1").run();
if (userId != null) sql("UPDATE users SET host_account = 1 WHERE id = ?").run(userId);
})();
}
/** The user id the host account bills to right now, or null. */
function hostOwner() {
return sql(`SELECT ${HOST_OWNER} AS id`).get()?.id ?? null;
}
module.exports = {
ACCOUNT_OWNED,
accountOfPeer,
stampFromPeer,
accountOfPath,
stampFromFolders,
setHostOwner,
hostOwner,
};
+35 -2
View File
@@ -6,7 +6,9 @@
* swap it back in later, touching nothing but that login. * swap it back in later, touching nothing but that login.
* *
* - `codex`: the whole `$CODEX_HOME/auth.json` (a ChatGPT login or an API * - `codex`: the whole `$CODEX_HOME/auth.json` (a ChatGPT login or an API
* key). Config, sessions, skills and history stay where they are. * key). Config, sessions, skills and history stay where they are. A
* switch then restarts Codex's shared app-server daemon when one runs,
* so running and new sessions follow the new login (`follow`).
* - `omp`: every credential Oh My Pi stores, read with `omp auth export` * - `omp`: every credential Oh My Pi stores, read with `omp auth export`
* and put back with `omp auth import -` (secrets on stdin, never argv). * and put back with `omp auth import -` (secrets on stdin, never argv).
* Import MERGES, so a switch first empties omp's own credential tables in * Import MERGES, so a switch first empties omp's own credential tables in
@@ -85,6 +87,15 @@ const codex = {
writeJson(codexAuthFile(), secret); writeJson(codexAuthFile(), secret);
return [codexAuthFile()]; return [codexAuthFile()];
}, },
// Codex ≥ 0.164 talks to the API through a shared app-server daemon that
// outlives every TUI and holds the login it started with: a new auth.json
// reached neither a running session nor a NEW one, which kept the old
// (often out-of-quota) account. Restarting the daemon makes it read the new
// login, and every open TUI reconnects to it on its own (verified on
// 0.164.0). No daemon running: nothing to restart — the next one reads the file.
follow() {
return restartCodexDaemon();
},
identify(auth) { identify(auth) {
if (auth.tokens && auth.tokens.id_token) { if (auth.tokens && auth.tokens.id_token) {
const c = jwtClaims(auth.tokens.id_token) || {}; const c = jwtClaims(auth.tokens.id_token) || {};
@@ -107,6 +118,26 @@ const codex = {
}, },
}; };
/** The control socket's link points into a per-user runtime dir that exists only while a daemon runs. */
const codexDaemonSocket = () =>
path.join(getCodexHome(), "app-server-control", "app-server-control.sock");
/** Restart the CODEX_HOME's app-server daemon when one is running; true when a restart was started. */
function restartCodexDaemon() {
if (!fs.existsSync(codexDaemonSocket())) return false;
const bin = process.env.AGENTHUB_CODEX_BIN || require("./ai-tools").detect("codex").path;
if (!bin) return false;
cp.execFile(
bin,
["app-server", "daemon", "restart"],
{ env: { ...process.env, CODEX_HOME: getCodexHome() }, timeout: 60000, windowsHide: true },
(e) => {
if (e) console.warn(`[tool-profiles] codex daemon restart failed: ${e.message}`);
}
);
return true;
}
// ── Oh My Pi ─────────────────────────────────────────────────────────── // ── Oh My Pi ───────────────────────────────────────────────────────────
const ompDir = () => process.env.AGENTHUB_OMP_DIR || path.join(os.homedir(), ".omp"); const ompDir = () => process.env.AGENTHUB_OMP_DIR || path.join(os.homedir(), ".omp");
@@ -405,7 +436,9 @@ function activateProfile(tool, name) {
} }
const target = readProfile(tool, name); const target = readProfile(tool, name);
const written = a.write(target.secret, live); const written = a.write(target.secret, live);
return { ok: true, name, savedOutgoing, written }; // `following`: running sessions take the new login without a restart of their own.
const following = typeof a.follow === "function" ? a.follow() : false;
return { ok: true, name, savedOutgoing, written, following };
} }
function deleteProfile(tool, name) { function deleteProfile(tool, name) {
+5
View File
@@ -790,6 +790,11 @@ const paths = {
addWorkspaces: { type: "array", items: { type: "integer" } }, addWorkspaces: { type: "array", items: { type: "integer" } },
removeWorkspaces: { type: "array", items: { type: "integer" } }, removeWorkspaces: { type: "array", items: { type: "integer" } },
signOut: { type: "boolean", enum: [true] }, signOut: { type: "boolean", enum: [true] },
hostAccount: {
type: "boolean",
description:
"An admin owns the host's Linux account: its sessions outside every workspace are billed to them (at most one; EBADROLE for a non-admin)",
},
quota: { $ref: "#/components/schemas/UserQuotaSetting" }, quota: { $ref: "#/components/schemas/UserQuotaSetting" },
}), }),
responses: { responses: {
+5
View File
@@ -489,6 +489,11 @@ const paths = {
items: { type: "string" }, items: { type: "string" },
description: "Files the switch wrote.", description: "Files the switch wrote.",
}, },
following: {
type: "boolean",
description:
"Codex: a running app-server daemon was restarted, so open and new sessions use the new login.",
},
}, },
}, },
"Switched." "Switched."
+2
View File
@@ -1643,6 +1643,8 @@ function ingestHook(body, pushSource = null, peer = { kind: "host" }) {
} else { } else {
touchLaneFromHook(hook_type, data, tool, body.run_id, peer); touchLaneFromHook(hook_type, data, tool, body.run_id, peer);
bindRunSession(body.run_id, data.session_id, hook_type, data.source, tool); bindRunSession(body.run_id, data.session_id, hook_type, data.source, tool);
// The Linux account it ran as: whose bill it is when no workspace owns it.
require("../lib/session-account").stampFromPeer(data.session_id, peer);
} }
} }
} finally { } finally {
+1 -1
View File
@@ -702,7 +702,7 @@ router.get("/:id/file", async (req, res) => {
function sessionStats(sessionId) { function sessionStats(sessionId) {
const totalEvents = stmts.sessionEventCount.get(sessionId)?.count ?? 0; const totalEvents = stmts.sessionEventCount.get(sessionId)?.count ?? 0;
const eventsByType = stmts.sessionEventTypeCounts.all(sessionId); const eventsByType = stmts.sessionEventTypeCounts.all(sessionId);
const tools = stmts.sessionToolUsageCounts.all(sessionId); const tools = stmts.sessionToolUsageCounts.all(sessionId, sessionId);
// Exact totals: `tools_used` is a top-15 list, so its sum is not the count. // Exact totals: `tools_used` is a top-15 list, so its sum is not the count.
// A tool call is a PreToolUse; a session with none (imported) counts PostToolUse. // A tool call is a PreToolUse; a session with none (imported) counts PostToolUse.
const toolTotals = stmts.sessionToolCallTotals.get(sessionId) || {}; const toolTotals = stmts.sessionToolCallTotals.get(sessionId) || {};
+22 -3
View File
@@ -54,8 +54,9 @@ const { listAudit, recordAccountChange } = require("../lib/audit");
const router = Router(); const router = Router();
// Which account each session belongs to — through its workspace, or a // Which account each session belongs to — through its workspace, a dashboard
// dashboard run on one (server/lib/scope.js's sessionWhere, for every user at once). // run on one, or (no workspace owns it) the Linux account it ran as
// (server/lib/scope.js's sessionWhere, for every user at once).
const OWNED_SESSIONS = ` const OWNED_SESSIONS = `
SELECT s.id AS session_id, l.owner_id AS uid SELECT s.id AS session_id, l.owner_id AS uid
FROM sessions s JOIN lanes l ON l.id = s.lane_id FROM sessions s JOIN lanes l ON l.id = s.lane_id
@@ -63,7 +64,9 @@ const OWNED_SESSIONS = `
UNION UNION
SELECT s.id, l.owner_id SELECT s.id, l.owner_id
FROM dashboard_runs r JOIN lanes l ON l.id = r.lane_id JOIN sessions s ON s.id = r.session_id FROM dashboard_runs r JOIN lanes l ON l.id = r.lane_id JOIN sessions s ON s.id = r.session_id
WHERE l.owner_id IS NOT NULL`; WHERE l.owner_id IS NOT NULL
UNION
${require("../lib/session-account").ACCOUNT_OWNED}`;
/** /**
* Map(user id → {cost, tokens}) per window, each window a UTC hour * Map(user id → {cost, tokens}) per window, each window a UTC hour
@@ -580,6 +583,18 @@ router.patch(
if (self && body.disabled) throw ESELF("you cannot disable yourself"); if (self && body.disabled) throw ESELF("you cannot disable yourself");
} }
const disabledChange = body.disabled !== undefined && body.disabled !== Boolean(u.disabled); const disabledChange = body.disabled !== undefined && body.disabled !== Boolean(u.disabled);
if (body.hostAccount !== undefined) {
if (typeof body.hostAccount !== "boolean") {
throw Object.assign(new Error("hostAccount: true or false"), { code: "EBADFIELD" });
}
if (body.hostAccount && (body.role ?? u.role) !== "admin") {
throw Object.assign(new Error("only an admin can own the host account"), {
code: "EBADROLE",
});
}
}
const hostChange =
body.hostAccount !== undefined && body.hostAccount !== Boolean(u.host_account);
if (body.password !== undefined) { if (body.password !== undefined) {
// Your own password: every rule. Someone else's: a starting one they replace. // Your own password: every rule. Someone else's: a starting one they replace.
if (self) { if (self) {
@@ -619,6 +634,10 @@ router.patch(
auth.setRole(u.username, body.role); auth.setRole(u.username, body.role);
done.push(`role ${body.role}`); done.push(`role ${body.role}`);
} }
if (hostChange) {
require("../lib/session-account").setHostOwner(body.hostAccount ? u.id : null);
done.push(body.hostAccount ? "owns the host account" : "no longer owns the host account");
}
for (const id of add) auth.assignWorkspace(id, u.username); for (const id of add) auth.assignWorkspace(id, u.username);
for (const id of remove) auth.unassignWorkspace(id, u.username); for (const id of remove) auth.unassignWorkspace(id, u.username);
if (add.length) done.push(`workspace ${add.join(", ")}`); if (add.length) done.push(`workspace ${add.join(", ")}`);