feat: v2.7.8 — Artifacts hub, clone related repos as extra folders, quieter resume prompt

- Artifacts (/artifacts): claude.ai pages sessions publish (captured from the
  Artifact tool's hook), pinned workspace files, skills and plugins, each
  kept as a snapshot; shared ones appear in a Hub and a skill or plugin
  installs into the viewer's own account (server/lib/artifacts.js,
  /api/artifacts, docs/plans/artifacts/).
- Workspace extra folders: clone one or more related repos beside the
  workspace and add them in one step (POST /api/lanes/:id/sources/clone).
- A pane that ended by itself while tmux kept running is no longer offered
  for resume (tmux.serverStartedAt in reconcileOrphans).
- Changes panel: a folder that is not a git repo opens on Files, and its
  files open from there.
- Plus the parallel sessions' work in this tree: File Explorer button in the
  panel header, user delete, RSA SSH keys, chat/terminal and pet fixes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-14 16:04:03 +07:00
parent e94038481e
commit 22de5009ff
79 changed files with 4617 additions and 231 deletions
+39 -8
View File
@@ -87,7 +87,7 @@ A dashboard shared by several people runs in **server mode** ([SERVER-MODE.md](S
In server mode a **developer** sees only rows of the workspaces they own (`lanes.owner_id`): every list, count, chart and cost answer is limited to them, and a request naming another user's session, agent, workspace or run (in the path, query or body) answers `404 ENOTFOUND`. Admin and analyst see everything; `GET /api/users` lists every account (`displayName`, `email`, `createdAt`, `logins`, `lastLoginAt` included) with its workspaces, sessions and 7/30-day `{cost, tokens}`, and `GET /api/users/audit[?limit=]` the staff reads of developers' sessions and admins' account changes (`reader`/`owner` are usernames, `reader_display`/`owner_display` their display names or null) (both `403 EADMIN` for a developer). See [SERVER-MODE.md](SERVER-MODE.md#what-each-user-sees).
An admin changes accounts with `POST /api/users` (`{username, password, role}` → `201 {user}`), `GET /api/users/:id` (`{user, workspaces: [{id, title, cwd}]}`, the account with `logins` and `lastLoginAt`, for the Edit form; admin or analyst), `PATCH /api/users/:id` (any of `{displayName?, email?, role?, disabled?, password?, addWorkspaces?: [laneId], removeWorkspaces?: [laneId], signOut?: true}` — every field validated before any is written; `removeWorkspaces` leaves the workspace with no owner and answers `409 ENOTOWNED` for one the user does not own; an admin's own `password`/`signOut` keeps the login it came from), `POST /api/users/:id/password` (`{password}`; that user's logins end) and `POST /api/users/:id/workspaces` (`{laneId}`; the workspace's owner becomes that user). Every one carries `currentPassword`, the admin's own password: `403 EREAUTH` when it is wrong or missing, `429 ETOOMANY` after 10 wrong tries in 15 minutes. `409 ESELF` for an admin's own role or disable, `409 EEXISTS` for a taken name, `400 EBADUSER`/`EBADPASSWORD`/`EBADROLE`/`EBADEMAIL`/`EBADFIELD`, `404 ENOUSER`/`ENOLANE`; a developer or analyst gets `403 EADMIN`, and local mode `403 ELOCALMODE`. No answer carries a password hash; each change is written to the audit log.
An admin changes accounts with `POST /api/users` (`{username, password, role}` → `201 {user}`), `GET /api/users/:id` (`{user, workspaces: [{id, title, cwd}]}`, the account with `logins` and `lastLoginAt`, for the Edit form; admin or analyst), `PATCH /api/users/:id` (any of `{displayName?, email?, role?, disabled?, password?, addWorkspaces?: [laneId], removeWorkspaces?: [laneId], signOut?: true}` — every field validated before any is written; `removeWorkspaces` leaves the workspace with no owner and answers `409 ENOTOWNED` for one the user does not own; an admin's own `password`/`signOut` keeps the login it came from), `POST /api/users/:id/password` (`{password}`; that user's logins end) `POST /api/users/:id/workspaces` (`{laneId}`; the workspace's owner becomes that user) and `DELETE /api/users/:id` (delete for good: the user's logins, panes, workspaces' records, prefs and — under S3 — their Linux account and whole home, removed FIRST so a refusal (`502 EPROVISION`) leaves every row; `409 ESELF` for yourself, `409 ELASTADMIN` for the last admin; session history and the audit log stay). Every one carries `currentPassword`, the admin's own password: `403 EREAUTH` when it is wrong or missing, `429 ETOOMANY` after 10 wrong tries in 15 minutes. `409 ESELF` for an admin's own role or disable, `409 EEXISTS` for a taken name, `400 EBADUSER`/`EBADPASSWORD`/`EBADROLE`/`EBADEMAIL`/`EBADFIELD`, `404 ENOUSER`/`ENOLANE`; a developer or analyst gets `403 EADMIN`, and local mode `403 ELOCALMODE`. No answer carries a password hash; each change is written to the audit log.
`GET /api/accounts[?force=1]` lists the Claude account pool ([SERVER-MODE.md](SERVER-MODE.md#claude-account-pool)): `{accounts: [{name, addedAt, expiresAt, expired, livePanes, spend5h, spend7d, quota: {fiveHour, sevenDay, checkedAt, error} | null, quotaPercent, full}], codex: [{name, kind: "apikey"|"chatgpt", addedAt, livePanes, spend5h, spend7d, quota: {windows: [{label, usedPercent, resetsAt}], asOf} | null, quotaPercent, full}], blockPercent}` — `codex` is the [Codex account pool](SERVER-MODE.md#codex-account-pool). It never includes a key or a token. `force=1` re-reads every quota first. Run rows (`/api/run/history`) carry `account`, the pool account their pane ran on (`null` = the machine's own login).
@@ -275,7 +275,7 @@ POST /api/lanes/:id/fs/upload?path=&conflict= raw body, streamed
GET /api/lanes/:id/fs/zip?p=<path>&p=… application/zip, streamed
```
The workspace **File Explorer** — the **Files** page in the sidebar (`/files?lane=<id>&path=<folder>`), laid out like GNOME Files (workspaces as places, Back/Forward, a path bar, icon grid or list, hidden files behind Ctrl+H), and the same manager in a dialog from a workspace's Files tab (the folder button; "Open in File Explorer" on a folder). `?session=` / `?source=` pick the same folder the
The workspace **File Explorer** (server mode only — a local dashboard has no **Files** entry, `/files` redirects home, and its buttons are hidden; the routes still answer) — the **Files** page in the sidebar (`/files?lane=<id>&path=<folder>`), laid out like GNOME Files (workspaces as places, Back/Forward, a path bar, icon grid or list, hidden files behind Ctrl+H), and the same manager in a dialog from a workspace's Files tab (the folder button; "Open in File Explorer" on a folder). `?session=` / `?source=` pick the same folder the
git reads do. Paths go through the read path's guards (`lanePath`, the parent
`resolveInLane`-confined, the last segment kept as a name so a symlink is acted
on as a link); nothing in `.git`, no secret file, never the workspace folder
@@ -1040,7 +1040,7 @@ cannot collide on `index.lock`.
```
GET /api/me/git { account: "member"|"host", writable, ssh: { publicKey, fingerprint } | null, https: [{ protocol, host, username, hasToken }] }
POST /api/me/git/ssh-key Make the SSH key — Body: { replace? } (409 EEXISTS without replace) → 201, same shape
POST /api/me/git/ssh-key Make the SSH key — Body: { replace?, type?: "ed25519" | "rsa" } (409 EEXISTS without replace) → 201, same shape
DELETE /api/me/git/ssh-key Delete it (404 ENOTFOUND)
POST /api/me/git/https Save an https login — Body: { host, username, password }
DELETE /api/me/git/https/:host Delete one (404 ENOTFOUND)
@@ -1058,8 +1058,7 @@ a prompt.
The signed-in user's OWN SSH key and HTTPS logins, used by `POST
/api/lanes/clone` (Settings → Git → **Your git identity**). With a Linux
account of their own (S3) everything lives in their home under
`~/.config/agenthub/` — `ssh/id_ed25519` (+`.pub`, an unencrypted ed25519
key in OpenSSH format, made by the server) and `git-credentials` (git's
`~/.config/agenthub/` — `ssh/id_ed25519` or `ssh/id_rsa` (+`.pub`, unencrypted, made by the server: ed25519 in OpenSSH format by default, or RSA 4096 as PKCS#1 PEM for hosts that take only RSA, such as AWS IAM/CodeCommit; one key per account, and `ssh.type` says which) and `git-credentials` (git's
`credential-store` format) — written only AS the member (`write-as`, `0600`)
and read through `readFileSafe`; no secret crosses sudo's argv or env.
Otherwise the account is the host's: the key is
@@ -1537,6 +1536,7 @@ POST /api/locks/:name/release
GET /api/lanes/:id/sources
PUT /api/lanes/:id/instructions {"instructions": "…" | null}
POST /api/lanes/:id/sources/folder {"path": "/abs/dir"}
POST /api/lanes/:id/sources/clone {"url": "https://host/o/r.git", "name"?: "r", "credentials"?: {username, password, remember?}}
POST /api/lanes/:id/sources/file raw body, Content-Type: application/octet-stream, X-File-Name: <URI-encoded name>
DELETE /api/lanes/:id/sources/:sourceId
GET /api/lanes/:id/memory
@@ -1559,6 +1559,7 @@ What a workspace's home page edits (`server/lib/lane-sources.js`, `server/routes
- **Instructions** — the `instructions` key is required (`400 EBADFIELD` without it), a string or `null`; blank clears; over 16 000 characters is `413 ETOOBIG`. The lane is re-broadcast (`lane_update`). The text is stored in `lanes.instructions` but is NOT part of the lane payload (`GET /api/lanes`, `lane_update`) — read it here.
- **Folder** — must be absolute, exist and be a directory (`400 EBADPATH`), must not be or contain the OS home (`400 EBADHOME`), and must not be the workspace's own folder or one it already has (`400 EDUP`). Stored by real path. Removing it only drops the row.
- **Clone** — clones a related repo BESIDE the workspace folder (into its parent; no path is taken from the request) and adds it as a Folder. The clone is `POST /api/lanes/clone`'s own: the same URL allowlist (`400 EBADURL`), one plain `name` (default: the repo's), `409 EEXISTS` for a taken name, run as the folder's account with that account's git identity, `409 ECREDENTIALS` (with `auth: {protocol, host}`) / `ESSHAUTH` / `ENOREPO`; a tool that takes no folders is `400 EUNSUPPORTED`. Answers the view plus `path`. Removing the folder later never deletes the clone. The workspace home's **Add folder** form takes several URLs, one per line, and clones them in turn.
- **File** — up to 10 MB (`413 ETOOBIG`), not empty (`400 EEMPTY`). AgentHub stores it under `<data dir>/workspace-context/<lane id>/` with a name it derives from `X-File-Name` (directory parts and path characters dropped, `name (2).ext` on a clash), so a request never names a path. It is a COPY taken at upload: later edits to the original are not seen — remove and upload it again, or add its directory as a Folder (read live from disk). Removing it deletes that copy. At most 50 sources per workspace (`400 ETOOMANY`).
- **Memory** — read-only: the folder's `CLAUDE.md`, `.claude/CLAUDE.md`, `CLAUDE.local.md`, `AGENTS.md`, and for a Claude workspace `<CLAUDE_HOME>/projects/<encoded cwd>/memory/*.md` — `{files: [{label, path, size, mtime, truncated, content}]}`, each at most 256 KB of content.
- Every write is same-origin only (`403 EBADORIGIN`); an unknown workspace is `404 ENOLANE`, an unknown source `404 ENOSOURCE`.
@@ -1780,7 +1781,7 @@ curl http://localhost:4820/api/sessions/sess_abc123/outline
GET /api/sessions/:id/image?path=<absolute path>
```
The bytes of an image the session's own `Read` tool read (a `PostToolUse` `Read` event of this session whose `tool_input.file_path` equals `path` exactly) — how a Chat pane's Files read preview shows a screenshot outside the workspace folder. Raster only (`png`, `jpg`/`jpeg`, `gif`, `webp`, else 400 `ENOTIMAGE`), at most 20 MB (`ETOOBIG`), served with a `sandbox` CSP, `nosniff` and `no-store`. 404 for an unknown session, a path the session never read, or a file that is gone; 403 `EUNSUPPORTED` for a session pushed from another machine (`push:<source>`), whose paths are not this host's to read, and in server mode without S3. With S3 a member's session (its folder under the users root) gets only a file inside that member's home that the member owns (else 404), a host session any file — a member's hook can only report sessions in its own home.
The bytes of an image the session's own `Read` tool read (a `PostToolUse` `Read` event of this session whose `tool_input.file_path` equals `path` exactly) — how a Chat pane's Files read preview shows a screenshot outside the workspace folder — or one the session NAMED (see `/file` below: a chart its script drew). Raster only (`png`, `jpg`/`jpeg`, `gif`, `webp`, else 400 `ENOTIMAGE`), at most 20 MB (`ETOOBIG`), served with a `sandbox` CSP, `nosniff` and `no-store`. 404 for an unknown session, a path the session never read, or a file that is gone; 403 `EUNSUPPORTED` for a session pushed from another machine (`push:<source>`), whose paths are not this host's to read, and in server mode without S3. With S3 a member's session (its folder under the users root) gets only a file inside that member's home that the member owns (else 404), a host session any file — a member's hook can only report sessions in its own home.
```bash
curl -o shot.png "http://localhost:4820/api/sessions/sess_abc123/image?path=/home/me/.cache/shot.png"
@@ -1794,7 +1795,7 @@ curl -o shot.png "http://localhost:4820/api/sessions/sess_abc123/image?path=/hom
GET /api/sessions/:id/file?path=<absolute path>
```
`{ "text": "...", "truncated": false }` for a text file the session's own `Read`, `Write`, `Edit`, `MultiEdit` or `NotebookEdit` named (a `PostToolUse` event of this session whose `tool_input.file_path` or `notebook_path` equals `path` exactly) — how a Chat pane's Outputs/Files preview shows a file outside the workspace folder (a report written to `~/Documents`). At most the first 512 KB (`truncated: true` beyond). 400 `EBADPATH` for a relative path; 403 `ESECRET` for a credential by name (`isSecretConfigPath` — `.credentials.json`, `auth.json`, AgentHub's own stores and data dir — or a `settings.local.json`), judged on the path and on what it resolves to; 415 `EBINARY` for a file with a NUL byte in its first 8 KB; 404 for an unknown session, a path the session never touched, or a non-regular or missing file. 403 `EUNSUPPORTED` and the S3 member rules are the same as for `/image` above.
`{ "text": "...", "truncated": false }` for a text file the session's own `Read`, `Write`, `Edit`, `MultiEdit` or `NotebookEdit` named (a `PostToolUse` event of this session whose `tool_input.file_path` or `notebook_path` equals `path` exactly) — how a Chat pane's Outputs/Files preview shows a file outside the workspace folder (a report written to `~/Documents`). A file the session only NAMED is served too (issue #76: a report its own script generated): `path` appears as a whole path — not as the prefix of a longer one — in one of its `PostToolUse` `Bash` events' `tool_input.command` or `tool_response.stdout`, or in a `Stop`/`SubagentStop` event's `last_assistant_message`, and the session's account could read it itself (`access(2)`, which ignores the S3 unit's `CAP_DAC_READ_SEARCH`; for a member's session the home/owner rule below decides). At most the first 512 KB (`truncated: true` beyond). 400 `EBADPATH` for a relative path; 403 `ESECRET` for a credential by name (`isSecretConfigPath` — `.credentials.json`, `auth.json`, AgentHub's own stores and data dir — or a `settings.local.json`), judged on the path and on what it resolves to; 415 `EBINARY` for a file with a NUL byte in its first 8 KB; 404 for an unknown session, a path the session never touched, or a non-regular or missing file. 403 `EUNSUPPORTED` and the S3 member rules are the same as for `/image` above.
```bash
curl "http://localhost:4820/api/sessions/sess_abc123/file?path=/home/me/Documents/REPORT.md"
@@ -2818,7 +2819,7 @@ DELETE /api/run/:id Kill (SIGTERM → SIGKILL after 5 s)
**PTY streaming:** Frames from the tmux pane are streamed to the client over `/ws-pty/:runId` as binary WebSocket frames (not JSON). The Workspace page's TerminalView component feeds these frames to xterm.js for live rendering. Simultaneously, `agenthub workspace shell` can attach the same session via a real local terminal, staying in sync with the browser view.
Every history row carries `lane_id` (the workspace whose run it belongs to) and `tool` (the CLI that was invoked: `"claude"` or `"codex"`). A run's `id` is its tmux pane name, which each Start in that pane reuses, so the run it replaces is kept as its own row with `id` = `<pane name>@<its started_at>` (status `abandoned` if it never recorded an end). A row still marked `running` is ended once its tmux session is gone — checked at server start and on the 10 s run-heal beat (`DASHBOARD_RUN_HEAL_MS`); a pane that survived a server restart stays `running`. It becomes `interrupted` when an agent session (not a Terminal tab, `session_id` known) died under the user — a reboot, a killed tmux, a crash; a Claude session the user ended (`SessionEnd` with reason `prompt_input_exit`/`logout`) and every row with no session become `abandoned`. Only Claude reports why it ended, so any other tool's vanished pane counts as interrupted. A pane the dashboard closed (Stop, close tab, Quit) is already `killed`. `GET /api/run/interrupted` lists the newest interrupted row of each session no newer run holds (`{id, session_id, lane_id, tool, cwd, model, permission_mode, effort, prompt_preview, started_at, ended_at, lane_title, lane_cwd, session_name}`), only the caller's own workspaces in server mode — the app-wide **Resume interrupted sessions?** dialog reads it; dismissing flips them to `abandoned`. The heal beat broadcasts `runs_interrupted` (no payload) whenever it ended a row, and every client refetches. `GET /api/run/history?laneId=<n>` returns only that workspace's runs. Spawned Claude processes fire the dashboard's hooks like any other CLI session, so they show up in `/api/sessions`, and the Dashboard's Analytics tab (Workflow sub-tab included) automatically. Codex runs do not emit these hooks; they are recorded only in run history and `dashboard_runs`.
Every history row carries `lane_id` (the workspace whose run it belongs to) and `tool` (the CLI that was invoked: `"claude"` or `"codex"`). A run's `id` is its tmux pane name, which each Start in that pane reuses, so the run it replaces is kept as its own row with `id` = `<pane name>@<its started_at>` (status `abandoned` if it never recorded an end). A row still marked `running` is ended once its tmux session is gone — checked at server start and on the 10 s run-heal beat (`DASHBOARD_RUN_HEAL_MS`); a pane that survived a server restart stays `running`. It becomes `interrupted` when an agent session (not a Terminal tab, `session_id` known) died under the user — a reboot, a killed tmux, a crash; a Claude session the user ended (`SessionEnd` with reason `prompt_input_exit`/`logout`) and every row with no session become `abandoned`. A pane whose tmux server has run since before the pane started ended by itself (the user quit Codex, `exit`ed, closed it), so it is `abandoned` too — for Claude only when it sent any `SessionEnd` (none at all is a crash); a pane whose server is gone or restarted, or cannot be asked, died with it and stays `interrupted`. A pane the dashboard closed (Stop, close tab, Quit) is already `killed`. `GET /api/run/interrupted` lists the newest interrupted row of each session no newer run holds (`{id, session_id, lane_id, tool, cwd, model, permission_mode, effort, prompt_preview, started_at, ended_at, lane_title, lane_cwd, session_name}`), only the caller's own workspaces in server mode — the app-wide **Resume interrupted sessions?** dialog reads it; dismissing flips them to `abandoned`. The heal beat broadcasts `runs_interrupted` (no payload) whenever it ended a row, and every client refetches. `GET /api/run/history?laneId=<n>` returns only that workspace's runs. Spawned Claude processes fire the dashboard's hooks like any other CLI session, so they show up in `/api/sessions`, and the Dashboard's Analytics tab (Workflow sub-tab included) automatically. Codex runs do not emit these hooks; they are recorded only in run history and `dashboard_runs`.
---
@@ -2939,6 +2940,36 @@ DELETE /api/snapshots/:id { ok: true }
A snapshot freezes a session as it is at that moment into one immutable `session_snapshots` row (`server/routes/snapshots.js`): `payload` is `{ version: 1, captured_at, redacted: { file_paths }, session, agents, events, stats, cost }` — the session row, its agents (each with its own `cost`), every stored event oldest first (`events.data` exactly as stored), the `/api/sessions/:id/stats` body and the `/api/pricing/cost/:id` result priced at the session's start date. Its `token` is 24 random bytes in base64url (32 characters) and is the only way to read it; the page is `/snapshot/<token>`. With `redact.file_paths`, paths are replaced **before anything is stored**: every `cwd`, `transcript_path`, `agent_transcript_path`, `file_path`, `filePath`, `notebook_path` and `path` value (also inside `events.data` and `metadata` JSON) becomes `[redacted]`, and the session's cwd / the home directory inside any other string (a Bash command, a summary) become `<cwd>` / `~`. A row cannot be updated (a trigger aborts it), outlives its session, and survives **Clear Data** — it goes only through `DELETE` (Settings → **Snapshots**). Both writes are same-origin (`403 EBADORIGIN`); `400 INVALID_INPUT` without `session_id`, `404 NOT_FOUND` for an unknown session, token or id.
### Artifacts hub
```http
GET /api/artifacts?scope=mine|hub&kind=page|file|skill|plugin&q= {items}
GET /api/artifacts/settings {serverMode, canShare, installsEnabled, canManage}
PUT /api/artifacts/settings {installsEnabled: boolean} — admin (any caller in local mode)
GET /api/artifacts/candidates?laneId= {items: [{path, sessionId, at, exists, pinned}]}
GET /api/artifacts/skills?laneId= {items: [{name, from: "user"|"workspace"}]}
POST /api/artifacts/page {url, title?, description?}
POST /api/artifacts/file {laneId, path, title?, sessionId?}
POST /api/artifacts/skill {name, laneId?, title?, description?}
POST /api/artifacts/plugin {key: "name@marketplace", market: "owner/repo" | git URL, title?, description?}
GET /api/artifacts/:id {item}
PATCH /api/artifacts/:id {title?, description?, shared?}
DELETE /api/artifacts/:id
GET /api/artifacts/:id/raw?path=&download=1 one snapshot file (sandbox CSP, nosniff)
POST /api/artifacts/:id/install {replace?} → {installed, replaced?}
```
What agents produced, per OWNER (`server/lib/artifacts.js`, plan: `docs/plans/artifacts/README.md`):
- **page** — a claude.ai link (`https://claude.ai/…` only, `400 EBADURL`), one row per owner and link. A session's `Artifact` tool publish is captured from its PostToolUse hook (the first `https://claude.ai/…artifact/…` in `tool_response`), owned by the session's workspace owner.
- **file** — `candidates` are the documents (html, md, pdf, images, csv, office…) the workspace's sessions wrote with Write/Edit/MultiEdit/NotebookEdit, inside its folder. A pin is read under the download route's guards (`lanePath`, `resolveInLane`, `ESECRET`, ≤ 25 MB, `413 ETOOBIG`) and SNAPSHOTTED to `<data dir>/artifacts/<random>/`.
- **skill** — the caller's own `~/.claude/skills/<name>` (their member home under S3; the host's for an admin or in local mode, else `409 ENOACCOUNT`) or a workspace's `.claude/skills/<name>`. Snapshotted whole: ≤ 300 files, ≤ 5 MB, a symlink anywhere is `ESYMLINK`, a secret-looking file name (`.env`, `*.pem`, `*token*`…) `403 ESECRET`.
- **plugin** — only the spec and its marketplace source (`EBADPLUGIN`, `EBADMARKET`: `owner/repo` or a `validateRemoteUrl` URL); no files.
- **Visibility** — local mode: every row, no sharing (`400 ELOCALMODE`). Server mode: a developer sees their own rows and shared ones, admin/analyst every row; only the owner or an admin changes or deletes one (`403 EFORBIDDEN`). `shared` puts a row in every user's **Hub**.
- **raw** — only a file the snapshot recorded (`404 ENOFILE` otherwise), never a request path; served with `Content-Security-Policy: …; sandbox` and `nosniff`, unknown types as an attachment.
- **install** — a skill or plugin (`EBADKIND`), into the CALLER's own account: a skill is written to `~/.claude/skills/<name>/` (as the member through `write-as` under S3); a taken name is `409 EEXISTS` unless `replace: true`, which moves the old one to `~/.claude/agenthub-replaced-skills/<name>-<time>/`. A plugin runs `claude plugin marketplace add <market>` then `claude plugin install --scope user <key>` as the caller, never with `-y`. `403 EINSTALLSOFF` while an admin has turned installs off. Share, unshare, a shared row's delete and every install by someone else are written to `audit_log`.
- Every write is same-origin. The `artifact_changed` broadcast (`{id, ownerId, shared}`) reaches everyone for a shared row, else its owner and staff.
### Query Explorer
The dashboard's Sessions page reads its **Agents** tab (`/sessions?view=agents`; the old `/query` page redirects there) from `entity=agents`; sessions and events have their own tabs over `GET /api/sessions` and `GET /api/events`. The route still answers all three entities for scripts and exports.
+7 -3
View File
File diff suppressed because one or more lines are too long
+15
View File
@@ -726,6 +726,21 @@ sudo agenthub server install-system
Until then the File Explorer's move, copy and delete in a member's folder
answer `409 EUPGRADE` (listing, upload, new folder/file and zip work).
The same re-run installs `agenthub-provision delete`, behind the Users page's
**Delete user**: until then a delete answers `502 EPROVISION` ("unknown
action: delete") and changes nothing.
### Deleting a user
Users page → Edit → **Delete user** (the admin's own password, then a red
confirm). Order: the user's logins end and their workspaces' panes stop;
under S3 `agenthub-provision delete <name>` locks `ah-<name>`, kills its
processes, `userdel`s it and removes `<USERS_ROOT>/<name>` (a real
directory exactly there — never a link — `rm -rf --one-file-system`); only
then are the rows removed (the user, their workspaces' records, prefs, push
subscriptions, personal AI Proxy profiles). Session history and the audit log
stay. Never yourself (`ESELF`) or the last admin (`ELASTADMIN`). A user made
again under the same name gets a NEW, empty Linux account and home.
### What a new machine needs before `install-system`
+44
View File
@@ -0,0 +1,44 @@
# Artifacts hub — plan
_Drafted 2026-10-14. Status: all three phases built 2026-10-14 (the session view's own Artifacts tab is not built; the page has the per-workspace file picker)._
## Goal
One place to keep what agents PRODUCE, and (in server mode) to share it:
| Kind | What it is | Where it comes from |
|---|---|---|
| `page` | A claude.ai Artifact a session published (`claude.ai/…/artifact/…`) | the `Artifact` tool's PostToolUse hook, or a link pasted by hand |
| `file` | A file an agent wrote (HTML, Markdown, PDF, image, CSV, Office…) | Write/Edit/NotebookEdit hook events, kept on the user's pin |
| `skill` | A Claude Code skill folder (`SKILL.md` + files) | the user's own `~/.claude/skills/<name>` or a workspace's `.claude/skills/<name>` |
| `plugin` | A plugin by marketplace source + name (no files) | the user's installed plugins (`claude plugin list`) |
- **Local mode:** one personal library, no sharing UI.
- **Server mode:** every artifact is the OWNER's and private by default. The owner can **Share** it; a shared artifact appears in the **Hub** for every signed-in user. A shared skill or plugin can be **installed** into the viewer's own account.
## Rules (binding, same spirit as CLAUDE.md)
- **What is shared is a SNAPSHOT, never the owner's live file.** Sharing a `file`/`skill` copies it (size-capped, regular files only, no symlinks followed) into `<data dir>/artifacts/<id>/`, written once like `session_snapshots`. Viewers read only that copy, so no route ever reads another user's home for them. Re-sharing makes a new snapshot.
- **Nothing shared runs on the dashboard's origin.** HTML/SVG previews are served under the `/git/raw` headers (`sandbox` CSP + `nosniff`) and shown in a sandboxed iframe; a `page` is only a link out, accepted only as `https://claude.ai/…`.
- **Installing is the viewer's explicit click, into the viewer's own account only.** For a skill, the hub writes the snapshot's files into `~/.claude/skills/<name>/` of the VIEWER (S3: as their member, through `write-as`). It never overwrites an existing skill without `conflict: replace`, and the old one goes to the trash first. For a plugin, the hub runs `claude plugin marketplace add <source>` + `claude plugin install <name>@<market> --scope user` as the viewer (the `applyPluginBaseline` path). The marketplace source passes an allowlist (`owner/repo` or a `validateRemoteUrl` URL), and no argv comes from the request beyond those two validated values.
- **Before installing, the dialog lists every file of the skill** (a skill may carry scripts that the viewer's agent will run). Every share, unshare and install is written to `audit_log`.
- **Visibility follows ownership:** a developer sees their own artifacts plus shared ones; admin/analyst see per `scope.js`, like sessions. Unshare removes an artifact from the Hub but does not uninstall copies others already installed.
## Data
New table `artifacts` (`CREATE TABLE IF NOT EXISTS` in `server/db.js`):
`id, kind, title, description, owner_id, lane_id, session_id, source (path | url | market spec), url, snapshot (dir name | NULL), size, shared (0/1), shared_at, installs, created_at, updated_at`, plus an index on `(owner_id, kind)` and on `(shared, kind)`.
## Phases
1. **Library (local + server, private).** Table, `server/lib/artifacts.js`, `/api/artifacts` (list/get/create/patch/delete). `page` rows are captured from the `Artifact` PostToolUse hook (URL taken from `tool_response` by an `https://claude.ai/…artifact/…` match). `file` candidates are listed per workspace from Write events with a document extension, and the user pins them. New **Artifacts** page (sidebar) plus an **Artifacts** tab in the session view's right panel. Scope wiring: `PATH_RULES`, `broadcastAudience`.
2. **Hub (server mode).** Share/Unshare buttons, snapshot, `Hub` tab, sandboxed preview, download, `artifact_shared` broadcast.
3. **Skills & plugins.** "Share a skill/plugin" picker, the install flow (S3 included), conflict handling, audit.
Each phase ships with its server and client tests and an update to `docs/API.md`.
## Open questions
- No `Artifact` tool call has happened on this machine yet, so the exact hook payload of a publish is unverified. Phase 1 matches the URL loosely and keeps "paste a link" as the fallback.
- Should an admin be able to disable Hub installs for everyone (one switch in Settings)? Proposed: yes, on by default.