Compare commits

...

7 Commits

Author SHA1 Message Date
nntrivi2001 ebfae9b891 chore: v2.8.47 — a Codex ChatGPT pool account reaches its pane even while an older login's daemon runs 2026-10-30 16:06:33 +07:00
nntrivi2001 3ab484ddc0 fix(pool): a Codex ChatGPT pool pane runs on the login placed for it
Codex >= 0.164 hands a plain TUI (no -c override) to the CODEX_HOME's
shared app-server daemon, which outlives every TUI and keeps the login it
started with. A ChatGPT pool account written into a member's auth.json
therefore reached no pane while their daemon still ran on the previous
login (reproduced on 0.164.0: the daemon kept sending the old key, a
--no-daemon TUI sent the new one). Such a pane now runs `codex
--no-daemon`, only when that binary's --help lists the flag (probed once
per path/size/mtime), so an older Codex still starts.
2026-10-30 16:06:20 +07:00
nntrivi2001 6bcef26446 chore: v2.8.46 — last updated time and installers' versions in Artifacts; a web app's pages are no longer auto-captured 2026-10-30 15:23:03 +07:00
nntrivi2001 240444eafd fix(artifacts): auto-capture leaves a web app's own HTML pages alone
At a session's Stop every untracked .html was snapshotted as a report, so a
web project not yet committed had its index.html taken into its owner's
Artifacts. An index.html, a page under a site folder (www, public, dist,
build, src, static, templates, assets, site, _site) or one beside scripts or
stylesheets (outside the workspace root) is now a site's own page and left
alone; a standalone HTML report is still captured.
2026-10-30 15:22:19 +07:00
nntrivi2001 d09c9d6633 feat(artifacts): last updated time on skills and files; each installer's copy marked
A row said nothing about when its content last changed, and the install list
could not tell who still runs an old version. `artifacts.version_at` (new
column, existing rows from `updated_at`) is stamped only when a snapshot is
replaced — a rename or share toggle leaves it — and served as `versionAt`:
skill and file rows read "Updated 3h ago", the detail sheet "Last updated".
`GET /api/artifacts/:id/installs` gives each skill install a `state` —
latest / outdated / removed / unknown — from its Claude skill copies' hash
against the newest snapshot, shown as a badge.
2026-10-30 15:21:31 +07:00
nntrivi2001 bc44cbc8b7 chore: v2.8.45 — Share again updates a shared skill; a same-named upload updates it too 2026-10-30 14:20:51 +07:00
nntrivi2001 e7060a5e42 feat(artifacts): Share again takes a skill's newest version; a same-named upload updates it
A shared skill followed its owner's ~/.claude/skills folder only, so one
uploaded from a browser (or living in a plugin) stayed at its first snapshot
for good. Share again (a skill's ⋯ menu and detail sheet) now takes the
owner's copy on the server — its source folder, else the same-named skill of
a plugin they installed at user scope — and shares it; with no copy it asks
for the folder and uploads it onto that row (`id`). An upload whose skill is
named like one the uploader already has replaces that row's version instead
of adding a twin. Installers are then offered Update, as before.
2026-10-30 14:20:18 +07:00
19 changed files with 723 additions and 43 deletions
+2 -2
View File
@@ -1,6 +1,6 @@
# Claude Code Working Guide # Claude Code Working Guide
_Last synced: v2.8.44 (2026-10-30) — `discard` takes the Changes panel's ticked files and blocks (`hunks`), never every file._ 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.45 (2026-10-30) — Artifacts **Share again** retakes a skill's newest version; a same-named upload updates the row._ 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.
@@ -60,7 +60,7 @@ A **workspace** — called a **lane** in the API, CLI, database and code, which
- **The File Explorer (`server/lib/file-explorer.js`, `/api/lanes/:id/fs/*`) reuses the read path's guards and never replaces silently.** Every path is `lanePath` + the PARENT `resolveInLane`-confined with the last segment kept as a name (so a symlink is moved/deleted as a link, never followed); never in or under `.git`, never a secret, never the workspace folder itself. Writes run as the folder's account — a member's only through `write-as` (`move`/`copy` modes; `writeAsAsync` streams an upload to its stdin; a batch upload of many small files is ONE `extract` of a tar the server writes itself — folders and regular files only, names it checked, `tar -k`) — and a move/copy never crosses accounts (`sameOwner`, `EFOREIGN`). A taken name is settled by `conflict` (`fail` → 409 with `names`, `skip`, `keep`, `replace` = the old one to the trash first). Delete is a move into the account's trash (member: `~/.local/share/agenthub/trash/`, host: `<data dir>/trash/<user id>/`), restorable 7 days. `write-as` is root-installed: a change to it needs `agenthub server install-system` again (`EUPGRADE` until then). A user's HOME is browsed through the same module inside `inHome` (`/api/homes/:who/fs/*`, `server/lib/homes.js` picks the folder): every dot entry at the TOP of a home stays hidden and refused — through a symlink, a download, a zip or as a copy source too — and another user's home is an admin's READ-ONLY, audited view (`EREADONLY`) — except its TRASH, whose restore/purge an admin may run (`TRASH_W` in `server/routes/homes.js`, the owner's trash via `trashKey`, audited with the item id); keep that exception to the trash routes only. A home's Trash is its USER's one recycle bin (`listUserTrash`: home-account store + host store `<data dir>/trash/<user id>/`, items tagged `store`). Do not add a delete that skips the trash, an overwrite without `conflict`, or a request path that is not run through `locate`/`folder`. - **The File Explorer (`server/lib/file-explorer.js`, `/api/lanes/:id/fs/*`) reuses the read path's guards and never replaces silently.** Every path is `lanePath` + the PARENT `resolveInLane`-confined with the last segment kept as a name (so a symlink is moved/deleted as a link, never followed); never in or under `.git`, never a secret, never the workspace folder itself. Writes run as the folder's account — a member's only through `write-as` (`move`/`copy` modes; `writeAsAsync` streams an upload to its stdin; a batch upload of many small files is ONE `extract` of a tar the server writes itself — folders and regular files only, names it checked, `tar -k`) — and a move/copy never crosses accounts (`sameOwner`, `EFOREIGN`). A taken name is settled by `conflict` (`fail` → 409 with `names`, `skip`, `keep`, `replace` = the old one to the trash first). Delete is a move into the account's trash (member: `~/.local/share/agenthub/trash/`, host: `<data dir>/trash/<user id>/`), restorable 7 days. `write-as` is root-installed: a change to it needs `agenthub server install-system` again (`EUPGRADE` until then). A user's HOME is browsed through the same module inside `inHome` (`/api/homes/:who/fs/*`, `server/lib/homes.js` picks the folder): every dot entry at the TOP of a home stays hidden and refused — through a symlink, a download, a zip or as a copy source too — and another user's home is an admin's READ-ONLY, audited view (`EREADONLY`) — except its TRASH, whose restore/purge an admin may run (`TRASH_W` in `server/routes/homes.js`, the owner's trash via `trashKey`, audited with the item id); keep that exception to the trash routes only. A home's Trash is its USER's one recycle bin (`listUserTrash`: home-account store + host store `<data dir>/trash/<user id>/`, items tagged `store`). Do not add a delete that skips the trash, an overwrite without `conflict`, or a request path that is not run through `locate`/`folder`.
- **The whole-file editor and the Files tab's upload are the two non-git writes on the lane file surface, and both live outside `git-ops.js` on purpose.** `git-ops.js` only touches paths `git status` reports as changed; `lane-file-write.js` may save any existing text file, so it must reuse the READ path's guards (`lanePath`, `resolveInLane`, `isSecretOnDisk`) and never be weaker than them, and it saves only over the `baseSha` it was opened at — do not let it create files or touch a binary. `lane-file-transfer.js`'s upload (`POST /api/lanes/:id/git/upload`, issue #78) is the opposite and only that: it CREATES one file (any bytes, ≤ 25 MB) under the same guards, into an existing folder, refuses a taken name (`EEXISTS`, a symlink included) and anything into or named `.git` (a hook there runs on the dashboard's next git call), and writes as the folder's member through `write-as` under S3 — do not let it overwrite, make folders or take a path from anything but `dir` + one plain name. Its `GET …/git/download` twin streams any file through `openSafe` as a `sandbox`ed attachment. The tree, expand-all and quick-open read the disk (`walkFolder`) for a folder that is not a repo. - **The whole-file editor and the Files tab's upload are the two non-git writes on the lane file surface, and both live outside `git-ops.js` on purpose.** `git-ops.js` only touches paths `git status` reports as changed; `lane-file-write.js` may save any existing text file, so it must reuse the READ path's guards (`lanePath`, `resolveInLane`, `isSecretOnDisk`) and never be weaker than them, and it saves only over the `baseSha` it was opened at — do not let it create files or touch a binary. `lane-file-transfer.js`'s upload (`POST /api/lanes/:id/git/upload`, issue #78) is the opposite and only that: it CREATES one file (any bytes, ≤ 25 MB) under the same guards, into an existing folder, refuses a taken name (`EEXISTS`, a symlink included) and anything into or named `.git` (a hook there runs on the dashboard's next git call), and writes as the folder's member through `write-as` under S3 — do not let it overwrite, make folders or take a path from anything but `dir` + one plain name. Its `GET …/git/download` twin streams any file through `openSafe` as a `sandbox`ed attachment. The tree, expand-all and quick-open read the disk (`walkFolder`) for a folder that is not a repo.
- **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; **Share again** (`POST /api/artifacts/:id/reshare`) takes the owner's folder on the server — else a user-scope plugin's same-named skill — on a click, and an upload named like the uploader's own skill, or carrying `id`, replaces that row's snapshot instead of adding a twin) — `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. 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 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.
+12 -1
View File
@@ -28,6 +28,12 @@
"unshare": "Unshare", "unshare": "Unshare",
"shareDone": "Shared to the Hub", "shareDone": "Shared to the Hub",
"unshareDone": "Removed from the Hub", "unshareDone": "Removed from the Hub",
"reshare": "Share again",
"reshareDone": "New version shared — people who installed it are offered Update",
"reshareSame": "Already the newest version — shared",
"reshareAskTitle": "Choose the {{name}} folder again",
"reshareAskBody": "This server has no copy of {{name}}. Choose the skill's folder (the one holding SKILL.md): it replaces this version, and people who installed it are offered Update. For a .zip, upload it through Add skill → Upload: a skill with the same name updates your existing one.",
"resharePick": "Choose folder",
"open": "Open", "open": "Open",
"copyLink": "Copy link", "copyLink": "Copy link",
"preview": "Preview", "preview": "Preview",
@@ -136,6 +142,11 @@
"installsYou": "You", "installsYou": "You",
"installsUnknown": "A removed user", "installsUnknown": "A removed user",
"installsReplaced": "replaced an older copy", "installsReplaced": "replaced an older copy",
"versionAt": "Updated {{ago}}",
"versionAtFull": "Last updated {{date}}",
"installState.latest": "Latest",
"installState.outdated": "Old version",
"installState.removed": "Removed",
"installsPluginWhere": "claude plugin install --scope user", "installsPluginWhere": "claude plugin install --scope user",
"installsUnrecorded": "{{count}} earlier installs were counted before details were kept", "installsUnrecorded": "{{count}} earlier installs were counted before details were kept",
"installsNoneOfYours": "You have not installed it — only your own installs are listed.", "installsNoneOfYours": "You have not installed it — only your own installs are listed.",
@@ -179,7 +190,7 @@
"stat.installs": "Installs", "stat.installs": "Installs",
"stat.files": "Files", "stat.files": "Files",
"stat.size": "Size", "stat.size": "Size",
"stat.updated": "Updated", "stat.updated": "Last updated",
"detail.runAs": "Run it as", "detail.runAs": "Run it as",
"detail.market": "Marketplace", "detail.market": "Marketplace",
"detail.added": "Added {{date}}", "detail.added": "Added {{date}}",
+12 -1
View File
@@ -28,6 +28,12 @@
"unshare": "Bỏ chia sẻ", "unshare": "Bỏ chia sẻ",
"shareDone": "Đã chia sẻ lên Hub", "shareDone": "Đã chia sẻ lên Hub",
"unshareDone": "Đã gỡ khỏi Hub", "unshareDone": "Đã gỡ khỏi Hub",
"reshare": "Share lại",
"reshareDone": "Đã share bản mới — người đã cài sẽ thấy nút Cập nhật",
"reshareSame": "Đã là bản mới nhất — đã share",
"reshareAskTitle": "Chọn lại thư mục {{name}}",
"reshareAskBody": "Server không có bản của {{name}}. Chọn thư mục của skill (thư mục chứa SKILL.md): bản này sẽ được thay, và người đã cài sẽ thấy nút Cập nhật. Với file .zip, tải lên qua Thêm skill → Tải lên: skill trùng tên sẽ cập nhật mục bạn đã có.",
"resharePick": "Chọn thư mục",
"open": "Mở", "open": "Mở",
"copyLink": "Sao chép link", "copyLink": "Sao chép link",
"preview": "Xem", "preview": "Xem",
@@ -137,6 +143,11 @@
"installsYou": "Bạn", "installsYou": "Bạn",
"installsUnknown": "Người dùng đã xoá", "installsUnknown": "Người dùng đã xoá",
"installsReplaced": "thay bản cũ", "installsReplaced": "thay bản cũ",
"versionAt": "Cập nhật {{ago}}",
"versionAtFull": "Cập nhật lần cuối {{date}}",
"installState.latest": "Bản mới nhất",
"installState.outdated": "Bản cũ",
"installState.removed": "Đã gỡ",
"installsPluginWhere": "claude plugin install --scope user", "installsPluginWhere": "claude plugin install --scope user",
"installsUnrecorded": "{{count}} lượt cài trước đây chỉ được đếm, không lưu chi tiết", "installsUnrecorded": "{{count}} lượt cài trước đây chỉ được đếm, không lưu chi tiết",
"installsNoneOfYours": "Bạn chưa cài — chỉ hiện các lượt cài của bạn.", "installsNoneOfYours": "Bạn chưa cài — chỉ hiện các lượt cài của bạn.",
@@ -180,7 +191,7 @@
"stat.installs": "Lượt cài", "stat.installs": "Lượt cài",
"stat.files": "File", "stat.files": "File",
"stat.size": "Dung lượng", "stat.size": "Dung lượng",
"stat.updated": "Cập nhật", "stat.updated": "Cập nhật lần cuối",
"detail.runAs": "Gọi bằng", "detail.runAs": "Gọi bằng",
"detail.market": "Marketplace", "detail.market": "Marketplace",
"detail.added": "Thêm lúc {{date}}", "detail.added": "Thêm lúc {{date}}",
+23 -6
View File
@@ -3324,16 +3324,17 @@ export const api = {
* several skills at once (one artifact each). `path` keeps a folder's layout. */ * several skills at once (one artifact each). `path` keeps a folder's layout. */
uploadSkills: ( uploadSkills: (
files: { file: File; path: string }[], files: { file: File; path: string }[],
fields: { name?: string; title?: string; description?: string } = {} /** `id`: Share again — the upload replaces that skill row's version. */
fields: { name?: string; title?: string; description?: string; id?: string } = {}
) => { ) => {
const form = new FormData(); const form = new FormData();
for (const f of files) form.append("files", f.file, f.file.name); for (const f of files) form.append("files", f.file, f.file.name);
form.append("paths", JSON.stringify(files.map((f) => f.path))); form.append("paths", JSON.stringify(files.map((f) => f.path)));
for (const [k, v] of Object.entries(fields)) if (v) form.append(k, v); for (const [k, v] of Object.entries(fields)) if (v) form.append(k, v);
return request<{ items: Artifact[] }>("/artifacts/skill/upload", { return request<{ items: (Artifact & { replaced?: boolean; changed?: boolean })[] }>(
method: "POST", "/artifacts/skill/upload",
body: form, { method: "POST", body: form }
}); );
}, },
/** One file from the user's computer, kept like a pinned one (≤ 25 MB). */ /** One file from the user's computer, kept like a pinned one (≤ 25 MB). */
uploadFile: (file: File, fields: { title?: string; description?: string } = {}) => { uploadFile: (file: File, fields: { title?: string; description?: string } = {}) => {
@@ -3372,6 +3373,12 @@ export const api = {
method: "PATCH", method: "PATCH",
body: JSON.stringify(body), body: JSON.stringify(body),
}), }),
/** POST /api/artifacts/:id/reshare — Share again: the owner's skill folder on
* the server becomes the new version (409 ENOSOURCE when it has none). */
reshare: (id: number) =>
request<{ item: Artifact; changed: boolean }>(`/artifacts/${id}/reshare`, {
method: "POST",
}),
/** `kept`: a shared one left your library but stays in the Hub. */ /** `kept`: a shared one left your library but stays in the Hub. */
remove: (id: number) => remove: (id: number) =>
request<{ ok: true; kept: boolean }>(`/artifacts/${id}`, { method: "DELETE" }), request<{ ok: true; kept: boolean }>(`/artifacts/${id}`, { method: "DELETE" }),
@@ -4713,11 +4720,21 @@ export interface Artifact {
suggestedForMe?: boolean; suggestedForMe?: boolean;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
/** When its content last changed (a new snapshot); a page or plugin: when it was added. */
versionAt: string;
} }
/** GET /api/artifacts/:id/installs. `paths` only for your own installs (or an admin). */ /** GET /api/artifacts/:id/installs. `paths` only for your own installs (or an admin). */
export interface ArtifactInstalls { export interface ArtifactInstalls {
items: { user: string | null; mine: boolean; at: string; replaced: boolean; paths?: string[] }[]; items: {
user: string | null;
mine: boolean;
at: string;
replaced: boolean;
/** Skills: where that install's copy stands against the newest version now. */
state?: "latest" | "outdated" | "removed" | "unknown";
paths?: string[];
}[];
/** Installs counted before the list was kept. */ /** Installs counted before the list was kept. */
unrecorded: number; unrecorded: number;
} }
+111 -4
View File
@@ -28,9 +28,14 @@
* **Installed** instead of the Install button; **Uninstall** (a skill's * **Installed** instead of the Install button; **Uninstall** (a skill's
* copies moved aside, a plugin `claude plugin uninstall`) is offered only in * copies moved aside, a plugin `claude plugin uninstall`) is offered only in
* the card's ⋯ menu. A skill follows its owner's folder (the server takes a * the card's ⋯ menu. A skill follows its owner's folder (the server takes a
* change there as a new snapshot when the Hub is listed); an installed copy * change there as a new snapshot when the Hub is listed; **Share again**
* takes its folder on the server — or a plugin's copy — on a click, else
* asks for the folder and uploads it onto the same row); an installed copy
* that differs from the newest version shows **Update** (a pack: "Update N"), * that differs from the newest version shows **Update** (a pack: "Update N"),
* which replaces it after a confirmation, the old copy moved aside. Editing a skill * which replaces it after a confirmation, the old copy moved aside. A skill or
* file row says when its CONTENT last changed ("Updated 3h ago", `versionAt`),
* and the install count's list marks each installer's copy Latest / Old
* version / Removed. Editing a skill
* edits its SKILL.md (name, description, instructions — a new snapshot); * edits its SKILL.md (name, description, instructions — a new snapshot);
* its preview opens with what it installs as and when Claude uses it. Deleting a shared * its preview opens with what it installs as and when Claude uses it. Deleting a shared
* row of your own only takes it out of your library — it stays in the Hub * row of your own only takes it out of your library — it stays in the Hub
@@ -79,6 +84,7 @@ import { toast } from "../lib/toast";
import { confirmDialog } from "../lib/confirm"; import { confirmDialog } from "../lib/confirm";
import { useDialog } from "../hooks/useDialog"; import { useDialog } from "../hooks/useDialog";
import { copyText } from "../lib/clipboard"; import { copyText } from "../lib/clipboard";
import { timeAgo } from "../lib/format";
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../components/ContextMenu"; import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../components/ContextMenu";
import { import {
FileUploadPanel, FileUploadPanel,
@@ -277,6 +283,53 @@ export function Artifacts() {
} }
}; };
// Share again: the server's copy of the skill, else the folder picked again
// (uploaded onto the same row). Either way installers are offered Update.
const reshareInput = useRef<HTMLInputElement>(null);
const reshareRow = useRef<Artifact | null>(null);
useEffect(() => {
reshareInput.current?.setAttribute("webkitdirectory", "");
}, []);
const reshared = (changed: boolean) => {
toast.success(t(changed ? "reshareDone" : "reshareSame"));
load();
};
const reshare = async (a: Artifact) => {
try {
const r = await api.artifacts.reshare(a.id);
reshared(r.changed);
} catch (err) {
if ((err as { code?: string }).code !== "ENOSOURCE") return toast.error(err);
if (
!(await confirmDialog({
title: t("reshareAskTitle", { name: a.source ?? a.title }),
message: t("reshareAskBody", { name: a.source ?? a.title }),
confirmLabel: t("resharePick"),
}))
)
return;
reshareRow.current = a;
reshareInput.current?.click();
}
};
const reshareUpload = async (list: FileList | null) => {
const a = reshareRow.current;
reshareRow.current = null;
if (!a || !list?.length) return;
try {
const r = await api.artifacts.uploadSkills(
Array.from(list).map((file) => ({
file,
path: (file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name,
})),
{ id: String(a.id) }
);
reshared(r.items.some((i) => i.changed !== false));
} catch (err) {
toast.error(err);
}
};
const [renaming, setRenaming] = useState<Artifact | null>(null); const [renaming, setRenaming] = useState<Artifact | null>(null);
const remove = async (a: Artifact) => { const remove = async (a: Artifact) => {
@@ -442,6 +495,7 @@ export function Artifacts() {
const actionsFor = (a: Artifact): CardActions => ({ const actionsFor = (a: Artifact): CardActions => ({
onOpen: () => setDetailId(a.id), onOpen: () => setDetailId(a.id),
onShare: () => void share(a), onShare: () => void share(a),
onReshare: () => void reshare(a),
onRename: () => { onRename: () => {
setDetailId(null); setDetailId(null);
setRenaming(a); setRenaming(a);
@@ -572,6 +626,17 @@ export function Artifacts() {
return ( return (
<div className="animate-fade-in space-y-5" data-testid="artifacts-page"> <div className="animate-fade-in space-y-5" data-testid="artifacts-page">
<input
ref={reshareInput}
type="file"
multiple
hidden
data-testid="artifact-reshare-folder"
onChange={(e) => {
void reshareUpload(e.target.files);
e.target.value = "";
}}
/>
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div className="min-w-0"> <div className="min-w-0">
<h1 className="text-lg font-semibold text-fg-primary">{t("title")}</h1> <h1 className="text-lg font-semibold text-fg-primary">{t("title")}</h1>
@@ -1066,6 +1131,8 @@ function EmptyState({
type CardActions = { type CardActions = {
onOpen: () => void; onOpen: () => void;
onShare: () => void; onShare: () => void;
/** A skill of your own: its newest version, shared (installers get Update). */
onReshare: () => void;
onRename: () => void; onRename: () => void;
onDelete: () => void; onDelete: () => void;
onInstall: () => void; onInstall: () => void;
@@ -1208,6 +1275,7 @@ function Meta({
server: boolean; server: boolean;
date?: boolean; date?: boolean;
}) { }) {
const { t } = useTranslation("artifacts");
const parts: React.ReactNode[] = []; const parts: React.ReactNode[] = [];
if (server && a.owner && !a.mine) if (server && a.owner && !a.mine)
parts.push( parts.push(
@@ -1215,7 +1283,18 @@ function Meta({
{a.owner} {a.owner}
</span> </span>
); );
if (date) // A skill or file is a snapshot that changes: when it last did. Anything else: when added.
if (a.kind === "skill" || a.kind === "file")
parts.push(
<span
key="at"
className="shrink-0"
title={t("versionAtFull", { date: new Date(a.versionAt).toLocaleString() })}
>
{t("versionAt", { ago: timeAgo(a.versionAt) })}
</span>
);
else if (date)
parts.push( parts.push(
<span key="at" className="shrink-0" title={new Date(a.createdAt).toLocaleString()}> <span key="at" className="shrink-0" title={new Date(a.createdAt).toLocaleString()}>
{fmtDate(a.createdAt)} {fmtDate(a.createdAt)}
@@ -1237,6 +1316,7 @@ function ArtifactMenu({
menu, menu,
onOpen, onOpen,
onShare, onShare,
onReshare,
onRename, onRename,
onDelete, onDelete,
onInstall, onInstall,
@@ -1297,6 +1377,9 @@ function ArtifactMenu({
{t(hubOnly ? "hubRemove" : a.shared ? "unshare" : "share")} {t(hubOnly ? "hubRemove" : a.shared ? "unshare" : "share")}
</MenuItem> </MenuItem>
)} )}
{server && a.kind === "skill" && (
<MenuItem onSelect={pick(onReshare)}>{t("reshare")}</MenuItem>
)}
<MenuItem onSelect={pick(onRename)}>{t("rename")}</MenuItem> <MenuItem onSelect={pick(onRename)}>{t("rename")}</MenuItem>
{!hubOnly && ( {!hubOnly && (
<MenuItem danger onSelect={pick(onDelete)}> <MenuItem danger onSelect={pick(onDelete)}>
@@ -1447,6 +1530,7 @@ function DetailDialog({
canInstall, canInstall,
onClose, onClose,
onShare, onShare,
onReshare,
onRename, onRename,
onDelete, onDelete,
onInstall, onInstall,
@@ -1468,7 +1552,10 @@ function DetailDialog({
: []), : []),
...(a.files.length ? [{ label: t("stat.files"), value: String(a.files.length) }] : []), ...(a.files.length ? [{ label: t("stat.files"), value: String(a.files.length) }] : []),
...(a.size ? [{ label: t("stat.size"), value: fmtBytes(a.size) }] : []), ...(a.size ? [{ label: t("stat.size"), value: fmtBytes(a.size) }] : []),
{ label: t("stat.updated"), value: fmtDate(a.updatedAt) }, {
label: t("stat.updated"),
value: new Date(a.versionAt).toLocaleString(),
},
]; ];
return ( return (
<Modal title={a.title} onClose={onClose} size="md"> <Modal title={a.title} onClose={onClose} size="md">
@@ -1611,6 +1698,11 @@ function DetailDialog({
{t(hubOnly ? "hubRemove" : a.shared ? "unshare" : "share")} {t(hubOnly ? "hubRemove" : a.shared ? "unshare" : "share")}
</button> </button>
)} )}
{server && a.kind === "skill" && (
<button type="button" className={secondary} onClick={onReshare}>
{t("reshare")}
</button>
)}
<button type="button" className={secondary} onClick={onRename}> <button type="button" className={secondary} onClick={onRename}>
{t("rename")} {t("rename")}
</button> </button>
@@ -1634,6 +1726,13 @@ function DetailDialog({
* The list floats over the page (a portal at the button, below it or above * The list floats over the page (a portal at the button, below it or above
* when there is no room, kept inside the window): inside the card list's * when there is no room, kept inside the window): inside the card list's
* `overflow-hidden` it was cut off at the last card. */ * `overflow-hidden` it was cut off at the last card. */
/** An installer's copy against the newest version: green, amber, grey. */
const STATE_CLASS = {
latest: "bg-status-success/15 text-status-success",
outdated: "bg-status-warning/15 text-status-warning",
removed: "bg-surface-2 text-fg-muted",
} as const;
function InstallsButton({ item }: { item: Artifact }) { function InstallsButton({ item }: { item: Artifact }) {
const { t } = useTranslation("artifacts"); const { t } = useTranslation("artifacts");
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
@@ -1725,6 +1824,14 @@ function InstallsButton({ item }: { item: Artifact }) {
<span className="ml-1 text-fg-muted">· {t("installsReplaced")}</span> <span className="ml-1 text-fg-muted">· {t("installsReplaced")}</span>
)} )}
</span> </span>
{i.state && i.state !== "unknown" && (
<span
data-testid="install-state"
className={`shrink-0 rounded px-1 text-[10px] ${STATE_CLASS[i.state]}`}
>
{t(`installState.${i.state}`)}
</span>
)}
<span className="shrink-0 text-[11px] text-fg-muted"> <span className="shrink-0 text-[11px] text-fg-muted">
{new Date(i.at).toLocaleString()} {new Date(i.at).toLocaleString()}
</span> </span>
+75 -1
View File
@@ -46,6 +46,7 @@ vi.mock("../../lib/api", () => ({
addPlugin: vi.fn(), addPlugin: vi.fn(),
inspectMarket: vi.fn(), inspectMarket: vi.fn(),
uploadSkills: vi.fn(), uploadSkills: vi.fn(),
reshare: vi.fn(),
writeSkill: vi.fn(), writeSkill: vi.fn(),
uploadFile: vi.fn(), uploadFile: vi.fn(),
candidates: vi.fn(), candidates: vi.fn(),
@@ -76,6 +77,7 @@ const item = (over: Partial<Artifact>): Artifact => ({
installs: 0, installs: 0,
createdAt: "2026-10-14T00:00:00Z", createdAt: "2026-10-14T00:00:00Z",
updatedAt: "2026-10-14T00:00:00Z", updatedAt: "2026-10-14T00:00:00Z",
versionAt: "2026-10-14T00:00:00Z",
...over, ...over,
}); });
@@ -348,9 +350,16 @@ describe("Artifacts", () => {
mine: true, mine: true,
at: "2026-10-19T10:00:00Z", at: "2026-10-19T10:00:00Z",
replaced: false, replaced: false,
state: "latest",
paths: ["/h/.claude/skills/deploy"], paths: ["/h/.claude/skills/deploy"],
}, },
{ user: "khoa", mine: false, at: "2026-10-18T10:00:00Z", replaced: true }, {
user: "khoa",
mine: false,
at: "2026-10-18T10:00:00Z",
replaced: true,
state: "outdated",
},
], ],
unrecorded: 1, unrecorded: 1,
}); });
@@ -362,6 +371,12 @@ describe("Artifacts", () => {
expect(await within(box).findByText("/h/.claude/skills/deploy")).toBeInTheDocument(); expect(await within(box).findByText("/h/.claude/skills/deploy")).toBeInTheDocument();
expect(within(box).getByText(/khoa/)).toBeInTheDocument(); expect(within(box).getByText(/khoa/)).toBeInTheDocument();
expect(within(box).getByText(/1 earlier installs/)).toBeInTheDocument(); expect(within(box).getByText(/1 earlier installs/)).toBeInTheDocument();
// Each install says whether its copy is the newest version.
expect(
within(box)
.getAllByTestId("install-state")
.map((b) => b.textContent)
).toEqual(["Latest", "Old version"]);
// Over the page, outside the card list that clips its overflow — and a // Over the page, outside the card list that clips its overflow — and a
// click inside it keeps it open. // click inside it keeps it open.
expect(box.parentElement).toBe(document.body); expect(box.parentElement).toBe(document.body);
@@ -394,6 +409,65 @@ describe("Artifacts", () => {
expect(calls[calls.length - 1]?.[0].title).toMatch(/Remove “hub-only” from the Hub/); expect(calls[calls.length - 1]?.[0].title).toMatch(/Remove “hub-only” from the Hub/);
}); });
it("Share again takes the server's copy, else uploads the picked folder onto that skill", async () => {
vi.mocked(api.artifacts.uploadSkills).mockClear();
vi.mocked(api.artifacts.settings).mockResolvedValue({
serverMode: true,
canShare: true,
installsEnabled: true,
canManage: true,
});
vi.mocked(api.artifacts.list).mockResolvedValue({
items: [item({ id: 2, kind: "skill", title: "sg-flow", source: "sg-flow", shared: true })],
});
vi.mocked(api.artifacts.reshare).mockResolvedValueOnce({
item: item({ id: 2 }),
changed: true,
});
const user = userEvent.setup();
open();
const card = await screen.findByTestId("artifact-2");
await menuAction(user, "Share again", card);
await waitFor(() => expect(api.artifacts.reshare).toHaveBeenCalledWith(2));
expect(api.artifacts.uploadSkills).not.toHaveBeenCalled();
// No copy on the server: the folder is asked for and lands on THIS row.
vi.mocked(api.artifacts.reshare).mockRejectedValueOnce(
Object.assign(new Error("no copy"), { code: "ENOSOURCE" })
);
vi.mocked(confirmDialog).mockResolvedValueOnce(true);
vi.mocked(api.artifacts.uploadSkills).mockResolvedValueOnce({
items: [{ ...item({ id: 2 }), changed: true }],
});
await menuAction(user, "Share again", card);
await waitFor(() => expect(confirmDialog).toHaveBeenCalled());
const md = new File(["---\nname: sg-flow\n---\n"], "SKILL.md");
await user.upload(screen.getByTestId("artifact-reshare-folder"), md);
await waitFor(() =>
expect(api.artifacts.uploadSkills).toHaveBeenCalledWith([{ file: md, path: "SKILL.md" }], {
id: "2",
})
);
});
it("says when a skill's content last changed, not when it was renamed", async () => {
vi.mocked(api.artifacts.list).mockResolvedValue({
items: [
item({
id: 2,
kind: "skill",
title: "deploy",
source: "deploy",
updatedAt: new Date().toISOString(),
versionAt: new Date(Date.now() - 3 * 3600_000).toISOString(),
}),
],
});
open();
const card = await screen.findByTestId("artifact-2");
expect(within(card).getByText("Updated 3h ago")).toBeInTheDocument();
});
it("offers Update for an installed skill its owner changed, after a confirmation", async () => { it("offers Update for an installed skill its owner changed, after a confirmation", async () => {
vi.mocked(api.artifacts.list).mockResolvedValue({ vi.mocked(api.artifacts.list).mockResolvedValue({
items: [ items: [
@@ -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.44 (rebuild client to match) UI build v2.8.47 (rebuild client to match)
</span> </span>
</p> </p>
</div> </div>
+6 -4
View File
@@ -3147,7 +3147,7 @@ A snapshot freezes a session as it is at that moment into one immutable `session
### Artifacts hub ### Artifacts hub
```http ```http
GET /api/artifacts?scope=all|mine|hub|unowned&kind=page|file|skill|plugin&q= {items} — each with `installed` and `updateAvailable` (default scope `all`) GET /api/artifacts?scope=all|mine|hub|unowned&kind=page|file|skill|plugin&q= {items} — each with `installed`, `updateAvailable` and `versionAt` (when its content last changed — a new snapshot; a rename or share toggle moves only `updatedAt`) (default scope `all`)
GET /api/artifacts/settings {serverMode, canShare, installsEnabled, canManage} GET /api/artifacts/settings {serverMode, canShare, installsEnabled, canManage}
PUT /api/artifacts/settings {installsEnabled: boolean} — admin (any caller in local mode); the UI is Settings → AI tools → Artifacts Hub PUT /api/artifacts/settings {installsEnabled: boolean} — admin (any caller in local mode); the UI is Settings → AI tools → Artifacts Hub
GET /api/artifacts/candidates?laneId= {items: [{path, sessionId, at, exists, pinned}]} GET /api/artifacts/candidates?laneId= {items: [{path, sessionId, at, exists, pinned}]}
@@ -3156,7 +3156,7 @@ POST /api/artifacts/page {url, title?, description?}
POST /api/artifacts/file {laneId, path, title?, sessionId?} POST /api/artifacts/file {laneId, path, title?, sessionId?}
POST /api/artifacts/file/upload multipart: files (one), title?, description? → {item} POST /api/artifacts/file/upload multipart: files (one), title?, description? → {item}
POST /api/artifacts/skill {name, laneId?, title?, description?} POST /api/artifacts/skill {name, laneId?, title?, description?}
POST /api/artifacts/skill/upload multipart: files, paths (JSON, each file's relative path), name?, title?, description? → {items} POST /api/artifacts/skill/upload multipart: files, paths (JSON, each file's relative path), name?, title?, description?, id? → {items} — a skill named like one the caller already has becomes its new version (`replaced`, `changed`); `id` = Share again onto that row
POST /api/artifacts/skill/new {name, description, content, title?} → {item, items} POST /api/artifacts/skill/new {name, description, content, title?} → {item, items}
POST /api/artifacts/plugin/inspect {market} → {market, name, description, plugins: [{name, key, description, version, category}]} POST /api/artifacts/plugin/inspect {market} → {market, name, description, plugins: [{name, key, description, version, category}]}
POST /api/artifacts/plugin {key: "name@marketplace", market: "owner/repo" | git URL | marketplace.json URL, title?, description?} POST /api/artifacts/plugin {key: "name@marketplace", market: "owner/repo" | git URL | marketplace.json URL, title?, description?}
@@ -3166,7 +3166,8 @@ DELETE /api/artifacts/:id {ok, kept} — kept: a shared row
GET /api/artifacts/:id/raw?path=&download=1 one snapshot file (sandbox CSP, nosniff) GET /api/artifacts/:id/raw?path=&download=1 one snapshot file (sandbox CSP, nosniff)
POST /api/artifacts/:id/install {replace?, update?} → {installed, replaced?} POST /api/artifacts/:id/install {replace?, update?} → {installed, replaced?}
POST /api/artifacts/:id/uninstall → {uninstalled, paths} POST /api/artifacts/:id/uninstall → {uninstalled, paths}
GET /api/artifacts/:id/installs {items: [{user, mine, at, replaced, paths?}], unrecorded} POST /api/artifacts/:id/reshare → {item, changed} — Share again (skill, owner): the owner's folder on this server becomes the new version and the row is shared; 409 ENOSOURCE when the server has no copy
GET /api/artifacts/:id/installs {items: [{user, mine, at, replaced, state?, paths?}], unrecorded} — state (skills): latest | outdated | removed | unknown, that install's Claude skill copies against the newest version now
POST /api/artifacts/:id/claim → {item} — the suggested user, or an admin POST /api/artifacts/:id/claim → {item} — the suggested user, or an admin
POST /api/artifacts/:id/decline → {id, declined} — the suggested user POST /api/artifacts/:id/decline → {id, declined} — the suggested user
POST /api/artifacts/:id/assign {userId} → {item} — admin POST /api/artifacts/:id/assign {userId} → {item} — admin
@@ -3175,8 +3176,9 @@ POST /api/artifacts/:id/assign {userId} → {item} — admin
What agents produced, per OWNER (`server/lib/artifacts.js`, plan: `docs/plans/artifacts/README.md`): 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 — or a Claude Docs `batch` that creates a doc (`container.create`; its title is the doc's name, later batches are skipped) — is captured from its PostToolUse hook (a doc's own `frame.artifactUrl`; for a publish the first `https://claude.ai/…artifact/…` in `tool_response`), owned by the session's workspace owner — or, for a session no workspace owns (a member's `claude` started from their own terminal under S3), by the member whose account sent the hook (by its connection's uid, never the body); the host account names nobody, so such a page from the host stays unowned, with `suggested_owner_id` set to the owner of the workspace whose folder holds the session's `cwd` (else, under S3, the member whose home it is). - **page** — a claude.ai link (`https://claude.ai/…` only, `400 EBADURL`), one row per owner and link. A session's `Artifact` tool publish — or a Claude Docs `batch` that creates a doc (`container.create`; its title is the doc's name, later batches are skipped) — is captured from its PostToolUse hook (a doc's own `frame.artifactUrl`; for a publish the first `https://claude.ai/…artifact/…` in `tool_response`), owned by the session's workspace owner — or, for a session no workspace owns (a member's `claude` started from their own terminal under S3), by the member whose account sent the hook (by its connection's uid, never the body); the host account names nobody, so such a page from the host stays unowned, with `suggested_owner_id` set to the owner of the workspace whose folder holds the session's `cwd` (else, under S3, the member whose home it is).
- **file** — `candidates` are the documents (html, md, pdf, images, csv, office…) the workspace's sessions wrote with Write/Edit/MultiEdit/NotebookEdit, inside its folder. **Captured at Stop:** when a session a workspace owns sends `Stop` or `SessionEnd`, each `.pptx`/`.docx`/`.xlsx`/`.pdf`/`.html` in the workspace it created or changed since it started (a repo's untracked or modified files — an HTML file only when untracked — or a plain folder's files; at most 20 per stop, ≤ 25 MB) is snapshotted into the workspace owner's library as `auto: true` (shown **Auto**), off the hook's response path. The same file at the mtime its snapshot recorded is skipped; a changed one replaces that row's snapshot; a file pinned by hand is left alone. A pin is read under the download route's guards (`lanePath`, `resolveInLane`, `ESECRET`, ≤ 25 MB, `413 ETOOBIG`) and SNAPSHOTTED to `<data dir>/artifacts/<random>/`. - **file** — `candidates` are the documents (html, md, pdf, images, csv, office…) the workspace's sessions wrote with Write/Edit/MultiEdit/NotebookEdit, inside its folder. **Captured at Stop:** when a session a workspace owns sends `Stop` or `SessionEnd`, each `.pptx`/`.docx`/`.xlsx`/`.pdf`/`.html` in the workspace it created or changed since it started (a repo's untracked or modified files — an HTML file only when untracked and not a web app's own page: never an `index.html`, one under `www`/`public`/`dist`/`build`/`src`/`static`/`templates`/`assets`/`site`/`_site`, or one beside `.js`/`.css`-type files in a folder other than the workspace root — or a plain folder's files; at most 20 per stop, ≤ 25 MB) is snapshotted into the workspace owner's library as `auto: true` (shown **Auto**), off the hook's response path. The same file at the mtime its snapshot recorded is skipped; a changed one replaces that row's snapshot; a file pinned by hand is left alone. 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`, `id_rsa`, `.netrc`…, or a data file named `*token*`/`*secret*`/`*password*`/`*credential*` — the word alone does not count in Markdown, source code or images, e.g. `design-tokens.md`) `403 ESECRET`. - **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`, `id_rsa`, `.netrc`…, or a data file named `*token*`/`*secret*`/`*password*`/`*credential*` — the word alone does not count in Markdown, source code or images, e.g. `design-tokens.md`) `403 ESECRET`.
- **Share again** (a skill's ⋯ menu and detail sheet, its owner, server mode) — `reshare` takes the owner's skill folder on THIS server as the new snapshot: the row's source folder, else the same-named `skills/<name>` of a plugin the owner installed at user scope (`installed_plugins.json`, the most recently updated one, never outside their `~/.claude/plugins/`), and shares the row; installers are then offered **Update**. With no copy on the server (a skill uploaded from a browser) it answers `409 ENOSOURCE` and the page asks for the skill's folder, uploaded with `id` onto that row. An upload whose skill is named like one the uploader already has (not a workspace's) also replaces that row's version rather than adding a twin.
- **file upload** — one file from the user's computer (≤ 25 MB, `413 ETOOBIG`), snapshotted under its own base name (no folder kept). - **file upload** — one file from the user's computer (≤ 25 MB, `413 ETOOBIG`), snapshotted under its own base name (no folder kept).
- **skill upload / new** — a skill made from files rather than picked: one Markdown/text file of any name becomes `SKILL.md`; several files or a picked/dropped folder (`paths` keeps the layout) need a `SKILL.md` (at the top or in one folder, which is stripped — `400 ENOSKILLMD` otherwise); a single `.zip`/`.skill` is unpacked (sizes checked from its directory first, a symlink entry `ESYMLINK`, `__MACOSX`/`.DS_Store`/`.git`/`node_modules` skipped); an upload holding several skill folders makes one artifact per skill, named by its folder, and stores NOTHING if any of them fails. The `name` the user typed (only for a single skill; else the frontmatter `name:`, the folder or the file name) is made a folder name (`skillSlug`: marks dropped, other characters → `-`; `400 EBADNAME` if nothing is left) and written into `SKILL.md`'s frontmatter as `name:`; a `description` given replaces the frontmatter's, and a skill with none anywhere is `400 ENODESCRIPTION`. Paths are relative with no `..` (`EBADPATH`); the same limits and `ESECRET` as a picked skill. `new` writes `SKILL.md` from `content` the same way. - **skill upload / new** — a skill made from files rather than picked: one Markdown/text file of any name becomes `SKILL.md`; several files or a picked/dropped folder (`paths` keeps the layout) need a `SKILL.md` (at the top or in one folder, which is stripped — `400 ENOSKILLMD` otherwise); a single `.zip`/`.skill` is unpacked (sizes checked from its directory first, a symlink entry `ESYMLINK`, `__MACOSX`/`.DS_Store`/`.git`/`node_modules` skipped); an upload holding several skill folders makes one artifact per skill, named by its folder, and stores NOTHING if any of them fails. The `name` the user typed (only for a single skill; else the frontmatter `name:`, the folder or the file name) is made a folder name (`skillSlug`: marks dropped, other characters → `-`; `400 EBADNAME` if nothing is left) and written into `SKILL.md`'s frontmatter as `name:`; a `description` given replaces the frontmatter's, and a skill with none anywhere is `400 ENODESCRIPTION`. Paths are relative with no `..` (`EBADPATH`); the same limits and `ESECRET` as a picked skill. `new` writes `SKILL.md` from `content` the same way.
- **plugin** — only the spec and its marketplace source (`EBADPLUGIN`, `EBADMARKET`: `owner/repo`, a `validateRemoteUrl` URL, or an https link to a `marketplace.json`); no files. A GitHub web link becomes `owner/repo`, and a forge's `…/src/…`, `…/tree/…`, `…/blob/…` suffix or trailing slash is trimmed. The same `key` added again by its owner updates that row. **inspect** reads the marketplace's `.claude-plugin/marketplace.json` to list its plugins: a git link is cloned shallow, blob-less and without checkout into a temp dir that is removed after, with every credential helper switched off and no prompt (a repository that needs a login is `502 ENEEDLOGIN`; type the key by hand — installing then uses each user's own git login), https only (an ssh link is `400 ESSHMARKET`); a repo with only a `plugin.json` is `400 ENOTMARKET`; unreachable `502 EMARKETFETCH`. It reaches any https host a signed-in user names, like `publish` does — no login is ever sent with it. - **plugin** — only the spec and its marketplace source (`EBADPLUGIN`, `EBADMARKET`: `owner/repo`, a `validateRemoteUrl` URL, or an https link to a `marketplace.json`); no files. A GitHub web link becomes `owner/repo`, and a forge's `…/src/…`, `…/tree/…`, `…/blob/…` suffix or trailing slash is trimmed. The same `key` added again by its owner updates that row. **inspect** reads the marketplace's `.claude-plugin/marketplace.json` to list its plugins: a git link is cloned shallow, blob-less and without checkout into a temp dir that is removed after, with every credential helper switched off and no prompt (a repository that needs a login is `502 ENEEDLOGIN`; type the key by hand — installing then uses each user's own git login), https only (an ssh link is `400 ESSHMARKET`); a repo with only a `plugin.json` is `400 ENOTMARKET`; unreachable `502 EMARKETFETCH`. It reaches any https host a signed-in user names, like `publish` does — no login is ever sent with it.
+4
View File
@@ -555,6 +555,10 @@ agenthub accounts pin team-key mia --tool codex # pins work as for Claude
is used only for a pane that runs as its developer (one Linux user per is used only for a pane that runs as its developer (one Linux user per
account): AgentHub writes the account's `auth.json` into that developer's account): AgentHub writes the account's `auth.json` into that developer's
`~/.codex` as them (0600) just before the pane starts. `~/.codex` as them (0600) just before the pane starts.
- The pane runs `codex --no-daemon` (when that Codex lists the flag in
`--help`): Codex ≥ 0.164 otherwise hands a plain session to the home's
shared app-server daemon, which outlives every pane and keeps the login
it started with — the new `auth.json` would not be used.
- A host pane never gets one: its `CODEX_HOME` holds the host's own login. - A host pane never gets one: its `CODEX_HOME` holds the host's own login.
- A developer who signed Codex in themselves keeps their own login. - A developer who signed Codex in themselves keeps their own login.
- While a pane of theirs runs on a pool account, their next pane uses the - While a pane of theirs runs on a pool account, their next pane uses the
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "agenthub", "name": "agenthub",
"version": "2.8.44", "version": "2.8.47",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "agenthub", "name": "agenthub",
"version": "2.8.44", "version": "2.8.47",
"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.44", "version": "2.8.47",
"bin": { "bin": {
"agenthub": "bin/agenthub.js" "agenthub": "bin/agenthub.js"
}, },
+139
View File
@@ -665,6 +665,128 @@ describe("skills from files or written", () => {
assert.equal(bad.body.error.code, "EBADNAME"); assert.equal(bad.body.error.code, "EBADNAME");
}); });
it("updates the uploader's same-named skill instead of adding a twin", async () => {
const md = (v) => `---\nname: sg-again\ndescription: version ${v}\n---\nBody ${v}.\n`;
const first = await upload("/api/artifacts/skill/upload", [["sg-again/SKILL.md", md(1)]]);
assert.equal(first.status, 201, JSON.stringify(first.body));
const id = first.body.items[0].id;
const again = await upload("/api/artifacts/skill/upload", [["sg-again/SKILL.md", md(2)]]);
assert.equal(again.status, 201, JSON.stringify(again.body));
assert.equal(again.body.items[0].id, id);
assert.equal(again.body.items[0].replaced, true);
assert.equal(again.body.items[0].changed, true);
assert.equal(again.body.items[0].description, "version 2");
assert.match(snapText(again.body.items[0]), /Body 2\./);
const rows = db
.prepare("SELECT COUNT(*) AS n FROM artifacts WHERE kind = 'skill' AND source = 'sg-again'")
.get();
assert.equal(rows.n, 1);
const same = await upload("/api/artifacts/skill/upload", [["sg-again/SKILL.md", md(2)]]);
assert.equal(same.body.items[0].changed, false);
});
it("dates a skill by its content, and tells each install's copy from the newest", async () => {
const md = (v) => `---\nname: sg-state\ndescription: d\n---\nState ${v}.\n`;
const first = await upload("/api/artifacts/skill/upload", [["sg-state/SKILL.md", md(1)]]);
const id = first.body.items[0].id;
const at1 = first.body.items[0].versionAt;
assert.ok(at1);
const states = async () =>
(await request("GET", `/api/artifacts/${id}/installs`)).body.items.map((i) => i.state);
assert.equal((await request("POST", `/api/artifacts/${id}/install`, {})).status, 200);
assert.deepEqual(await states(), ["latest"]);
// A rename is no new version; new content is.
const renamed = await request("PATCH", `/api/artifacts/${id}`, { title: "State" });
assert.equal(renamed.body.item.versionAt, at1);
await new Promise((r) => setTimeout(r, 5));
const next = await upload("/api/artifacts/skill/upload", [["sg-state/SKILL.md", md(2)]]);
assert.ok(next.body.items[0].versionAt > at1);
assert.deepEqual(await states(), ["outdated"]);
fs.rmSync(path.join(process.env.CLAUDE_HOME, "skills", "sg-state"), { recursive: true });
assert.deepEqual(await states(), ["removed"]);
});
it("Share again: an uploaded skill asks for its folder, which replaces that row", async () => {
const md = (v) => `---\nname: sg-up\ndescription: d\n---\nUp ${v}.\n`;
const first = await upload("/api/artifacts/skill/upload", [["sg-up/SKILL.md", md(1)]]);
const id = first.body.items[0].id;
const no = await request("POST", `/api/artifacts/${id}/reshare`);
assert.equal(no.status, 409);
assert.equal(no.body.error.code, "ENOSOURCE");
// The folder picked again, under any folder name: onto THIS row.
const r = await upload("/api/artifacts/skill/upload", [["renamed/SKILL.md", md(2)]], {
id: String(id),
});
assert.equal(r.status, 201, JSON.stringify(r.body));
assert.equal(r.body.items[0].id, id);
assert.equal(r.body.items[0].source, "sg-up");
assert.match(snapText(r.body.items[0]), /^---\nname: sg-up\n[\s\S]*Up 2\./);
const two = await upload(
"/api/artifacts/skill/upload",
[
["p/a/SKILL.md", "---\nname: a\ndescription: d\n---\n"],
["p/b/SKILL.md", "---\nname: b\ndescription: d\n---\n"],
],
{ id: String(id) }
);
assert.equal(two.body.error.code, "EBADUPLOAD");
});
it("Share again takes the owner's folder, else the skill of an installed plugin", async () => {
const home = process.env.CLAUDE_HOME;
// An uploaded skill whose name a user-scope plugin also ships.
const first = await upload("/api/artifacts/skill/upload", [
["sg-plug/SKILL.md", "---\nname: sg-plug\ndescription: d\n---\nOld.\n"],
]);
const id = first.body.items[0].id;
const writePlugin = (ver, text) => {
const at = path.join(home, "plugins", "cache", "m", "p", ver);
fs.mkdirSync(path.join(at, "skills", "sg-plug"), { recursive: true });
fs.writeFileSync(path.join(at, "skills", "sg-plug", "SKILL.md"), text);
return at;
};
const v1 = writePlugin("1.0.0", "---\nname: sg-plug\ndescription: d\n---\nPlugin one.\n");
const v2 = writePlugin("2.0.0", "---\nname: sg-plug\ndescription: d\n---\nPlugin two.\n");
fs.writeFileSync(
path.join(home, "plugins", "installed_plugins.json"),
JSON.stringify({
version: 2,
plugins: {
"p@m": [{ scope: "user", installPath: v2, lastUpdated: "2026-10-30T00:00:00Z" }],
"old@m": [{ scope: "user", installPath: v1, lastUpdated: "2026-01-01T00:00:00Z" }],
"proj@m": [
{ scope: "project", installPath: "/etc", lastUpdated: "2027-01-01T00:00:00Z" },
],
},
})
);
const r = await request("POST", `/api/artifacts/${id}/reshare`);
assert.equal(r.status, 200, r.text);
assert.equal(r.body.changed, true);
assert.match(snapText(r.body.item), /Plugin two\./);
assert.equal((await request("POST", `/api/artifacts/${id}/reshare`)).body.changed, false);
// The owner's own ~/.claude/skills folder outranks a plugin's copy.
const own = path.join(home, "skills", "sg-plug");
fs.mkdirSync(own, { recursive: true });
fs.writeFileSync(
path.join(own, "SKILL.md"),
"---\nname: sg-plug\ndescription: d\n---\nMine.\n"
);
const mine = await request("POST", `/api/artifacts/${id}/reshare`);
assert.equal(mine.body.changed, true);
assert.match(snapText(mine.body.item), /Mine\./);
// Recorded as the folder's state: the 15 s sync does not take it twice.
assert.deepEqual(
artifacts.syncSkillSources({ force: true }).filter((c) => c.id === id),
[]
);
fs.rmSync(path.join(home, "plugins"), { recursive: true, force: true });
fs.rmSync(own, { recursive: true, force: true });
});
it("keeps an uploaded file as a snapshot, by its own base name", async () => { it("keeps an uploaded file as a snapshot, by its own base name", async () => {
const r = await upload("/api/artifacts/file/upload", [["report.html", "<h1>x</h1>"]], { const r = await upload("/api/artifacts/file/upload", [["report.html", "<h1>x</h1>"]], {
title: "Report", title: "Report",
@@ -859,6 +981,23 @@ describe("outputs captured at a session's stop", () => {
); );
assert.deepEqual(await artifacts.captureOutputs("no-such-session"), []); assert.deepEqual(await artifacts.captureOutputs("no-such-session"), []);
}); });
it("leaves a web app's own pages alone and still takes a standalone HTML report", async () => {
const put = (rel, text = "<p>x</p>") => {
fs.mkdirSync(path.dirname(path.join(ws, rel)), { recursive: true });
fs.writeFileSync(path.join(ws, rel), text);
const soon = new Date(Date.now() + 5 * 60 * 1000);
fs.utimesSync(path.join(ws, rel), soon, soon);
};
put("app/index.html");
put("app/www/page.html");
put("widget/view.html");
put("widget/view.js", "x()");
put("summary-report.html");
put("out/q3.html");
const got = (await artifacts.captureOutputs("s1")).map((r) => r.source).sort();
assert.deepEqual(got, ["out/q3.html", "summary-report.html"]);
});
}); });
describe("unowned rows", () => { describe("unowned rows", () => {
+27 -1
View File
@@ -243,6 +243,29 @@ describe("codex pool: spawn", () => {
assert.equal(row.account, "c"); assert.equal(row.account, "c");
}); });
it("runs a ChatGPT pane without the shared daemon, only when its Codex has the flag", () => {
// The home's app-server daemon keeps the login it started with: a placed
// login reaches only a pane that runs its own app server.
const fakeCodex = (name, help) => {
const file = path.join(TMP, name);
fs.writeFileSync(file, `#!/bin/sh\necho '${help}'\n`, { mode: 0o755 });
return file;
};
const prev = process.env.AGENTHUB_CODEX_BIN;
try {
pool.addAccount("c", { auth: AUTH("acct-c") });
process.env.AGENTHUB_CODEX_BIN = fakeCodex("codex-new", " --no-daemon Run without");
assert.deepEqual(pool.forSpawn(MIA).argv, ["--no-daemon"]);
process.env.AGENTHUB_CODEX_BIN = fakeCodex("codex-old", " --oss Use a local model");
assert.deepEqual(pool.forSpawn(MIA).argv, [], "an older Codex would refuse the flag");
process.env.AGENTHUB_CODEX_BIN = path.join(TMP, "no-such-codex");
assert.deepEqual(pool.forSpawn(MIA).argv, []);
} finally {
if (prev === undefined) delete process.env.AGENTHUB_CODEX_BIN;
else process.env.AGENTHUB_CODEX_BIN = prev;
}
});
it("never replaces a member's own login, and stays on the account their live pane uses", () => { it("never replaces a member's own login, and stays on the account their live pane uses", () => {
pool.addAccount("c", { auth: AUTH("acct-c") }); pool.addAccount("c", { auth: AUTH("acct-c") });
pool.addAccount("d", { auth: AUTH("acct-d") }); pool.addAccount("d", { auth: AUTH("acct-d") });
@@ -272,7 +295,10 @@ describe("codex pool: spawn", () => {
fs.writeFileSync(MIA_AUTH, JSON.stringify(own)); fs.writeFileSync(MIA_AUTH, JSON.stringify(own));
assert.equal(pool.pickAccount(MIA), null); assert.equal(pool.pickAccount(MIA), null);
pty.spawnRun({ laneId: 50, cwd: path.join(MIA.home, "p"), tool: "codex" }); pty.spawnRun({ laneId: 50, cwd: path.join(MIA.home, "p"), tool: "codex" });
assert.equal(JSON.parse(fs.readFileSync(MIA_AUTH, "utf8")).tokens.refresh_token, "rt-mias-seat"); assert.equal(
JSON.parse(fs.readFileSync(MIA_AUTH, "utf8")).tokens.refresh_token,
"rt-mias-seat"
);
}); });
it("copies the newest auth.json back into the pool", () => { it("copies the newest auth.json back into the pool", () => {
+5
View File
@@ -973,11 +973,16 @@ for (const [col, ddl] of [
["suggested_owner_id", "suggested_owner_id INTEGER"], ["suggested_owner_id", "suggested_owner_id INTEGER"],
["version", "version TEXT"], ["version", "version TEXT"],
["source_hash", "source_hash TEXT"], ["source_hash", "source_hash TEXT"],
["version_at", "version_at TEXT"],
]) { ]) {
try { try {
db.prepare(`SELECT ${col} FROM artifacts LIMIT 1`).get(); db.prepare(`SELECT ${col} FROM artifacts LIMIT 1`).get();
} catch { } catch {
db.prepare(`ALTER TABLE artifacts ADD COLUMN ${ddl}`).run(); db.prepare(`ALTER TABLE artifacts ADD COLUMN ${ddl}`).run();
// `version_at`: when a row's CONTENT last changed (a new snapshot) — a
// rename or a share toggle moves `updated_at` but not this. An existing
// row starts from its `updated_at`, the best record there is.
if (col === "version_at") db.prepare("UPDATE artifacts SET version_at = updated_at").run();
} }
} }
+222 -16
View File
@@ -173,6 +173,8 @@ function view(row, req, owners = null) {
suggestedForMe: !!req?.user && row.owner_id == null && row.suggested_owner_id === req.user.id, suggestedForMe: !!req?.user && row.owner_id == null && row.suggested_owner_id === req.user.id,
createdAt: row.created_at, createdAt: row.created_at,
updatedAt: row.updated_at, updatedAt: row.updated_at,
// When its content last changed (a new snapshot): a page or plugin never does.
versionAt: row.version_at || row.created_at,
}; };
} }
@@ -549,13 +551,38 @@ async function pinFile(req, { laneId, path: rel, title, description, sessionId }
const OUTPUT_EXT = /\.(pptx|docx|xlsx|pdf|html?)$/i; const OUTPUT_EXT = /\.(pptx|docx|xlsx|pdf|html?)$/i;
/** Most files one stop captures, so a script that writes hundreds stays bounded. */ /** Most files one stop captures, so a script that writes hundreds stays bounded. */
const OUTPUT_MAX = 20; const OUTPUT_MAX = 20;
/** Folders that hold a site's own pages, never a report someone reads. */
const SITE_DIR_RE = /^(www|public|dist|build|src|static|templates|assets|site|_site)$/i;
/** What sits next to a site's page: its scripts and styles. */
const SITE_ASSET_RE = /\.(m?js|jsx|tsx?|vue|svelte|css|s[ac]ss|less)$/i;
/**
* An HTML file that is a web app's own page, not a report (`app/www/index.html`
* of a project not yet committed): an `index.html`, one under a site folder
* (`SITE_DIR_RE`), or one beside scripts or stylesheets in its folder. The
* workspace root is exempt from that last test — a repo keeps its config
* `.js` there next to the reports people write.
*/
async function isSitePage(cwd, rel) {
const parts = rel.split("/");
const name = parts.pop();
if (/^index\.html?$/i.test(name)) return true;
if (parts.some((d) => SITE_DIR_RE.test(d))) return true;
if (!parts.length) return false;
try {
const names = await fs.promises.readdir(path.join(cwd, ...parts));
return names.some((n) => SITE_ASSET_RE.test(n));
} catch {
return false;
}
}
const capturing = new Map(); const capturing = new Map();
/** /**
* The workspace's documents a session created or changed since it started: * The workspace's documents a session created or changed since it started:
* untracked or modified files in a repo (an HTML file only when untracked, so a * untracked or modified files in a repo (an HTML file only when untracked and
* site's own pages are not taken), every file of a plain folder. Paths are * not a site's own page — `isSitePage`), every file of a plain folder. Paths
* workspace-relative. * are workspace-relative.
*/ */
async function outputCandidates(lane, since) { async function outputCandidates(lane, since) {
const gd = require("./git-diff"); const gd = require("./git-diff");
@@ -572,7 +599,7 @@ async function outputCandidates(lane, since) {
const out = []; const out = [];
for (const rel of new Set(paths)) { for (const rel of new Set(paths)) {
if (!OUTPUT_EXT.test(rel)) continue; if (!OUTPUT_EXT.test(rel)) continue;
if (/\.html?$/i.test(rel) && tracked.has(rel)) continue; if (/\.html?$/i.test(rel) && (tracked.has(rel) || (await isSitePage(lane.cwd, rel)))) continue;
let st; let st;
try { try {
st = await fs.promises.lstat(path.join(lane.cwd, rel)); st = await fs.promises.lstat(path.join(lane.cwd, rel));
@@ -627,7 +654,7 @@ async function captureOutputsNow(laneId, sessionId) {
const files = JSON.stringify([{ path: shot.name, size: shot.size, mtime: mtimeMs }]); const files = JSON.stringify([{ path: shot.name, size: shot.size, mtime: mtimeMs }]);
if (row) { if (row) {
sql( sql(
"UPDATE artifacts SET snapshot = ?, files = ?, size = ?, session_id = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?" "UPDATE artifacts SET snapshot = ?, files = ?, size = ?, session_id = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now'), version_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?"
).run(shot.snap.id, files, shot.size, String(sessionId), row.id); ).run(shot.snap.id, files, shot.size, String(sessionId), row.id);
dropSnapshot(row.snapshot); dropSnapshot(row.snapshot);
done.push(sql("SELECT * FROM artifacts WHERE id = ?").get(row.id)); done.push(sql("SELECT * FROM artifacts WHERE id = ?").get(row.id));
@@ -1125,8 +1152,20 @@ function saveSkillFiles(req, { name, files, title, description }) {
* `paths`). Answers every skill it made. Nothing is stored unless every * `paths`). Answers every skill it made. Nothing is stored unless every
* skill in the upload passes. * skill in the upload passes.
*/ */
function uploadSkills(req, { uploads, name, title, description }) { function uploadSkills(req, { uploads, name, title, description, id }) {
// `id`: Share again for a skill the server has no copy of — the upload
// replaces THAT row (one skill, named as the row is).
let onto = null;
if (id != null && id !== "") {
onto = load(req, id);
if (!canChange(req, onto)) throw fail("EFORBIDDEN", "only its owner changes an artifact");
if (onto.kind !== "skill") throw fail("EBADKIND", "only a skill is shared again");
name = onto.source;
}
const found = skillsFromUpload(uploads || [], { name, description }); const found = skillsFromUpload(uploads || [], { name, description });
if (onto && found.length !== 1) {
throw fail("EBADUPLOAD", "choose the folder or .zip of this one skill");
}
const typed = typeof name === "string" && name.trim(); const typed = typeof name === "string" && name.trim();
const prepared = found.map((sk) => ({ const prepared = found.map((sk) => ({
name: validSkillName(found.length === 1 && typed ? typed : sk.fallbackName || ""), name: validSkillName(found.length === 1 && typed ? typed : sk.fallbackName || ""),
@@ -1140,16 +1179,46 @@ function uploadSkills(req, { uploads, name, title, description }) {
withFrontmatter(md.data.toString("utf8"), p.name, found.length === 1 ? description : ""); withFrontmatter(md.data.toString("utf8"), p.name, found.length === 1 ? description : "");
checkSkillFiles(p.files); checkSkillFiles(p.files);
} }
return prepared.map((p) => return prepared.map((p) => {
saveSkillFiles(req, { // A skill its uploader already has (same name, not a workspace's) gets
// the new version on that row — installers see Update — never a twin.
const own = onto || ownSkillRow(req, p.name);
if (own) return replaceUploaded(req, own, p, found.length === 1 ? description : "");
return saveSkillFiles(req, {
name: p.name, name: p.name,
files: p.files, files: p.files,
title: found.length === 1 ? title : "", title: found.length === 1 ? title : "",
description: found.length === 1 ? description : "", description: found.length === 1 ? description : "",
}) });
});
}
/** The caller's newest skill row of that name taken from no workspace, or null. */
function ownSkillRow(req, name) {
const owner = req?.user ? req.user.id : null;
return (
sql(
`SELECT * FROM artifacts WHERE kind = 'skill' AND source = ? AND lane_id IS NULL
AND snapshot IS NOT NULL AND owner_id IS ? ORDER BY updated_at DESC, id DESC LIMIT 1`
).get(name, owner) || null
); );
} }
/** An uploaded skill onto an existing row: same frontmatter rules as a new one. */
function replaceUploaded(req, row, { name, files }, description) {
const md = files.find((f) => f.path === "SKILL.md");
const text = md.data.toString("utf8");
if (text.includes("\uFFFD")) throw fail("EBADSKILL", "SKILL.md is not a UTF-8 text file");
const fixed = Buffer.from(withFrontmatter(text, name, description), "utf8");
const all = [{ path: "SKILL.md", data: fixed }, ...files.filter((f) => f !== md)];
checkSkillFiles(all);
const changed = contentHash(all) !== snapshotVersion(row);
// The owner's folder as it is now (like `storeSkill`): only a LATER change
// to it replaces this upload.
if (changed) replaceSnapshot(row, all, sourceHash(row) ?? row.source_hash);
return { ...shareNow(req, row.id, changed), replaced: true, changed };
}
/** A skill written in the dialog: a name, a description and its instructions. */ /** A skill written in the dialog: a name, a description and its instructions. */
function writeSkill(req, { name, description, content, title }) { function writeSkill(req, { name, description, content, title }) {
const n = validSkillName(name); const n = validSkillName(name);
@@ -1599,7 +1668,8 @@ function editSkill(req, row, { name, description, content }) {
sql( sql(
`UPDATE artifacts SET source = ?, snapshot = ?, files = ?, size = ?, description = ?, `UPDATE artifacts SET source = ?, snapshot = ?, files = ?, size = ?, description = ?,
version = ?, source_hash = ?, version = ?, source_hash = ?,
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?` updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now'),
version_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`
).run( ).run(
n, n,
snap.id, snap.id,
@@ -1936,6 +2006,23 @@ function syncOne(row) {
} catch { } catch {
return false; return false;
} }
try {
replaceSnapshot(row, files, st.hash);
} catch {
return false;
}
return true;
}
/**
* Point a skill row at a NEW snapshot holding `files` (`[{path, data}]`, already
* checked), then drop the old one — never a write into the snapshot being
* read. Installers then see Update (`updateCheck`). `sourceHash` is the
* source folder's state to record, so `syncOne` takes only a LATER change.
* The description follows SKILL.md's only while it still says what the old
* one did.
*/
function replaceSnapshot(row, files, sourceHash) {
const snap = newSnapshotDir(); const snap = newSnapshotDir();
let total = 0; let total = 0;
const next = []; const next = [];
@@ -1947,11 +2034,10 @@ function syncOne(row) {
total += f.data.length; total += f.data.length;
next.push({ path: f.path, size: f.data.length }); next.push({ path: f.path, size: f.data.length });
} }
} catch { } catch (err) {
dropSnapshot(snap.id); dropSnapshot(snap.id);
return false; throw err;
} }
// The description follows SKILL.md's only while it still says what the old one did.
const md = (list) => list.find((f) => f.path === "SKILL.md"); const md = (list) => list.find((f) => f.path === "SKILL.md");
let oldSummary = ""; let oldSummary = "";
try { try {
@@ -1969,10 +2055,98 @@ function syncOne(row) {
: row.description; : row.description;
sql( sql(
`UPDATE artifacts SET snapshot = ?, files = ?, size = ?, version = ?, source_hash = ?, `UPDATE artifacts SET snapshot = ?, files = ?, size = ?, version = ?, source_hash = ?,
description = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?` description = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now'),
).run(snap.id, JSON.stringify(next), total, st.hash, st.hash, description, row.id); version_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`
).run(
snap.id,
JSON.stringify(next),
total,
contentHash(files),
sourceHash ?? null,
description,
row.id
);
if (row.snapshot && SNAPSHOT_RE.test(row.snapshot)) dropSnapshot(row.snapshot); if (row.snapshot && SNAPSHOT_RE.test(row.snapshot)) dropSnapshot(row.snapshot);
return true; }
/**
* The owner's skill folder on THIS server as it is now, for Share again:
* the row's own source folder (`sourceDirOf`), else — a skill not taken from
* a workspace — the same-named skill of a plugin the owner installed at user
* scope (`installed_plugins.json`; the most recently updated when several
* have it), never outside the owner's `~/.claude/plugins/`. Answers
* `{st, fromSource}` (`folderState`) or null when the server has no copy (an
* uploaded skill: its owner picks the folder again).
*/
function machineSkill(row) {
const src = sourceDirOf(row);
const st = src && folderState(src.dir, src.top);
if (st) return { st, fromSource: true };
if (!src || row.lane_id != null) return null;
const top = path.join(src.top, "plugins");
let installed = {};
try {
const user = sql("SELECT id, username, role FROM users WHERE id = ?").get(row.owner_id);
const file = path.join(top, "installed_plugins.json");
const text =
user && ownAccount({ user }).acct
? require("./run-as").readFileSafe(file, "utf8", { max: 4 << 20 })
: fs.readFileSync(file, "utf8");
installed = JSON.parse(text)?.plugins || {};
} catch {
return null;
}
let best = null;
for (const entries of Object.values(installed)) {
for (const e of Array.isArray(entries) ? entries : []) {
if (!e || e.scope !== "user" || typeof e.installPath !== "string") continue;
const st2 = folderState(path.join(e.installPath, "skills", row.source), top);
const at = Date.parse(e.lastUpdated || e.installedAt || "") || 0;
if (st2 && (!best || at > best.at)) best = { st: st2, at };
}
}
return best ? { st: best.st, fromSource: false } : null;
}
/**
* Share again: take the owner's skill folder on this server as the row's new
* snapshot (`machineSkill`) and share it (server mode), so installers are
* offered Update. ENOSOURCE when the server has no copy — the client then
* asks for the folder or .zip and uploads it onto this row (`uploadSkills`
* with `id`). Answers `{item, changed}`.
*/
function reshareSkill(req, id) {
const row = load(req, id);
if (!canChange(req, row)) throw fail("EFORBIDDEN", "only its owner changes an artifact");
if (row.kind !== "skill") throw fail("EBADKIND", "only a skill is shared again");
const found = machineSkill(row);
if (!found) {
throw fail("ENOSOURCE", "this server has no copy of the skill: choose its folder or .zip");
}
const files = found.st.read();
checkSkillFiles(files);
const changed = found.st.hash !== snapshotVersion(row);
if (changed) {
replaceSnapshot(row, files, found.fromSource ? found.st.hash : row.source_hash);
} else if (found.fromSource) {
sql("UPDATE artifacts SET source_hash = ? WHERE id = ?").run(found.st.hash, row.id);
}
return { item: shareNow(req, row.id, changed), changed };
}
/** Share a row (server mode, when it is not yet) and answer its view; `audit` names a new version. */
function shareNow(req, id, changed) {
const row = sql("SELECT * FROM artifacts WHERE id = ?").get(id);
if (isServer() && row.shared !== 1) {
sql(
`UPDATE artifacts SET shared = 1, shared_at = strftime('%Y-%m-%dT%H:%M:%fZ','now'),
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`
).run(id);
audit(req, row, "shared");
} else if (changed && row.shared === 1) {
audit(req, row, "updated");
}
return view(sql("SELECT * FROM artifacts WHERE id = ?").get(id), req);
} }
const SYNC_EVERY_MS = 15 * 1000; const SYNC_EVERY_MS = 15 * 1000;
@@ -2163,6 +2337,36 @@ async function uninstall(req, id) {
* the installer and an admin. `unrecorded` = installs counted before this * the installer and an admin. `unrecorded` = installs counted before this
* list was kept. * list was kept.
*/ */
/**
* Where one recorded install of a skill stands now, from its Claude
* `skills/<name>` copies (one per account it went into; the cross-tool
* `~/.agents/skills` twins are the same files) —
* `outdated` when any differs from the row's newest snapshot (its user is
* offered Update), `removed` when none is there any more, `unknown` when one
* is there but unreadable as a skill, else `latest`. Only hashes are
* compared; no content leaves the server. The paths are the ones the server
* itself recorded at install, read like `updateCheck` reads the viewer's own.
*/
function installState(row, paths) {
const version = snapshotVersion(row);
const dirs = (Array.isArray(paths) ? paths : []).filter(
(p) =>
typeof p === "string" &&
path.basename(p) === row.source &&
path.basename(path.dirname(p)) === "skills" &&
path.basename(path.dirname(path.dirname(p))) !== ".agents"
);
if (!version || !dirs.length) return "unknown";
const states = dirs.map((dir) => {
const st = folderState(dir, path.dirname(path.dirname(dir)));
if (st) return st.hash === version ? "latest" : "outdated";
return exists(dir) ? "unknown" : "removed";
});
if (states.includes("outdated")) return "outdated";
if (states.every((x) => x === "removed")) return "removed";
return states.includes("unknown") ? "unknown" : "latest";
}
function installsOf(req, id) { function installsOf(req, id) {
const row = load(req, id); const row = load(req, id);
const u = req?.user; const u = req?.user;
@@ -2186,6 +2390,7 @@ function installsOf(req, id) {
mine, mine,
at: r.installed_at, at: r.installed_at,
replaced: r.replaced === 1, replaced: r.replaced === 1,
...(row.kind === "skill" ? { state: installState(row, paths) } : {}),
...(mine || u?.role === "admin" ? { paths } : {}), ...(mine || u?.role === "admin" ? { paths } : {}),
}; };
}); });
@@ -2215,6 +2420,7 @@ module.exports = {
availableSkills, availableSkills,
addSkill, addSkill,
uploadSkills, uploadSkills,
reshareSkill,
writeSkill, writeSkill,
uploadFile, uploadFile,
skillSlug, skillSlug,
+44 -2
View File
@@ -19,7 +19,9 @@
* home AgentHub placed that account in, never dated in the future, and * home AgentHub placed that account in, never dated in the future, and
* only when its id_token names the same account (`mayCopyBack`); the 2026-10-08 * only when its id_token names the same account (`mayCopyBack`); the 2026-10-08
* spike showed an old refresh token still refreshes, so panes sharing an * spike showed an old refresh token still refreshes, so panes sharing an
* account do not log each other out. * account do not log each other out. Such a pane runs `--no-daemon`
* (when its Codex has the flag): the home's shared app-server daemon
* would keep serving the login it started with.
* *
* Records live in `<CLAUDE_HOME>/agenthub-codex-accounts/<name>.json` (dir * Records live in `<CLAUDE_HOME>/agenthub-codex-accounts/<name>.json` (dir
* 0700, file 0600); no route returns a key or a token. * 0700, file 0600); no route returns a key or a token.
@@ -424,6 +426,46 @@ function liveInHome(name, member) {
}); });
} }
// Codex ≥ 0.164 hands a plain TUI (no `-c` override) to the CODEX_HOME's shared
// app-server daemon, which outlives every TUI and keeps the login it started
// with — so a ChatGPT login placed in the home reached no pane while the
// member's daemon ran on the previous one (reproduced on 0.164.0). A pool pane
// runs its own app server instead. An older Codex has neither the daemon nor
// the flag (it would refuse to start), so the flag goes only to a binary whose
// --help lists it: probed once per binary (path, size, mtime).
const noDaemonCache = new Map();
function noDaemonArgv(member) {
const bin =
process.env.AGENTHUB_CODEX_BIN ||
(member
? require("./shared-tools").sharedPath("codex")
: require("./ai-tools").detect("codex").path);
if (!bin) return [];
let key;
try {
const st = fs.statSync(bin);
key = `${fs.realpathSync(bin)}:${st.size}:${st.mtimeMs}`;
} catch {
return [];
}
if (!noDaemonCache.has(key)) {
let help = "";
try {
help = require("node:child_process").execFileSync(bin, ["--help"], {
encoding: "utf8",
timeout: 5000,
stdio: ["ignore", "pipe", "ignore"],
windowsHide: true,
});
} catch {
/* no answer: leave the flag out, as before */
}
noDaemonCache.set(key, /--no-daemon\b/.test(help));
}
return noDaemonCache.get(key) ? ["--no-daemon"] : [];
}
/** `{name, kind}` of the account a new Codex pane would get, or null. */ /** `{name, kind}` of the account a new Codex pane would get, or null. */
function pickAccount(member = null, ownerId = null) { function pickAccount(member = null, ownerId = null) {
const a = pick(member, ownerId); const a = pick(member, ownerId);
@@ -449,7 +491,7 @@ function forSpawn(member = null, ownerId = null) {
} }
return { return {
name: a.name, name: a.name,
argv: [], argv: noDaemonArgv(member),
env: {}, env: {},
place: () => { place: () => {
syncHome(member.home); // a live pane may hold a newer login than ours syncHome(member.home); // a live pane may hold a newer login than ours
+2 -1
View File
@@ -238,7 +238,7 @@ const paths = {
summary: "Skills from files: a file, a folder, a .zip/.skill or several skills", summary: "Skills from files: a file, a folder, a .zip/.skill or several skills",
operationId: "uploadArtifactSkills", operationId: "uploadArtifactSkills",
description: description:
"`paths` is a JSON array of each file's relative path. Zip sizes are read before inflating; nothing is stored if any skill fails.", "`paths` is a JSON array of each file's relative path. Zip sizes are read before inflating; nothing is stored if any skill fails. A skill whose name matches one the caller already has (not a workspace's) becomes that row's new version (`replaced: true`, `changed`) instead of a second row; `id` (Share again) puts the ONE uploaded skill onto that row, under the row's name.",
requestBody: multipart( requestBody: multipart(
{ {
files: { type: "array", items: { type: "string", format: "binary" } }, files: { type: "array", items: { type: "string", format: "binary" } },
@@ -246,6 +246,7 @@ const paths = {
name: { type: "string" }, name: { type: "string" },
title: { type: "string" }, title: { type: "string" },
description: { type: "string" }, description: { type: "string" },
id: { type: "string", description: "Share again: the skill row the upload replaces." },
}, },
["files"] ["files"]
), ),
+20
View File
@@ -1113,6 +1113,26 @@ const paths = {
}, },
}, },
}, },
"/api/artifacts/{id}/reshare": {
post: {
tags: ["Artifacts"],
summary: "Share again: a skill's newest version from its owner's folder on this server",
operationId: "reshareArtifact",
description:
"The owner's skill folder (the row's source folder, else the same-named skill of a plugin the owner installed at user scope) becomes the new snapshot, and the row is shared (server mode); installers are then offered Update. 409 ENOSOURCE when the server has no copy: upload the folder with `id` on /api/artifacts/skill/upload. Same-origin only.",
parameters: [pathParam("id", "Artifact id.")],
responses: {
200: ok("Taken.", {
item: { type: "object" },
changed: { type: "boolean" },
}),
400: errs("EBADKIND."),
403: errs("EFORBIDDEN."),
404: errs("ENOTFOUND."),
409: errs("ENOSOURCE."),
},
},
},
"/api/artifacts/{id}/claim": { "/api/artifacts/{id}/claim": {
post: { post: {
tags: ["Artifacts"], tags: ["Artifacts"],
+15
View File
@@ -39,6 +39,7 @@ const STATUS = {
ENEEDLOGIN: 502, ENEEDLOGIN: 502,
ETOOBIG: 413, ETOOBIG: 413,
EOWNED: 409, EOWNED: 409,
ENOSOURCE: 409,
}; };
function sendError(res, err) { function sendError(res, err) {
@@ -229,6 +230,7 @@ router.post(
name: b.name, name: b.name,
title: b.title, title: b.title,
description: b.description, description: b.description,
id: b.id,
}); });
for (const item of items) changed(item); for (const item of items) changed(item);
res.status(201).json({ items }); res.status(201).json({ items });
@@ -284,6 +286,19 @@ router.patch(
}) })
); );
// Share again: the owner's skill folder on this server becomes the new
// version (installers see Update); 409 ENOSOURCE when the server has none —
// the client then uploads the folder onto this row (`/skill/upload` + `id`).
router.post(
"/:id/reshare",
sameOriginGuard,
wrap((req, res) => {
const out = artifacts.reshareSkill(req, req.params.id);
changed(out.item);
res.json(out);
})
);
router.delete( router.delete(
"/:id", "/:id",
sameOriginGuard, sameOriginGuard,