- Artifacts: folders are rounded chips with a count badge; rename/delete are
icons beside the picked folder (double-click renames); New folder is a
dashed chip; the empty-folder hint mentions drag and drop.
- GET /api/pricing/quota: every Claude row carries its accountUuid.
- Settings → Profiles and the status bar's Usage panel: a profile hidden in
this browser is left out of the Usage panel too; when the live login is
hidden, the first visible profile is shown in its place (Logged in now,
"in use", the % used chip with that profile's own usage). Display only.
- update: before the --ff-only merge, put back package-lock.json /
client/package-lock.json when they are the ONLY tracked changes (an
earlier update's npm install rewrote them; v2.8.71 changed
client/package-lock.json and the merge refused on such installs). Any
other change still makes the merge refuse.
- Artifacts: drag a row or card onto a folder chip to put it there, onto
"All folders" to take it out.
Remote clients (docs/plans/remote-clients/README.md, B1–B3 for the Claude pool):
- server/hub/ holds the hub-only code (devices, remote sessions, upload ingest,
LAN gateway), required only in server mode; hub-boundary.test.js keeps local
code from importing it.
- A local install enrolls with `agenthub remote login` or Settings → AI tools →
Company hub; the "Company account" AI Proxy (reserved name agenthub.company)
sends a session through the hub's gateway, which adds a pool account's token —
no credential reaches the machine. Only those sessions are uploaded, hourly
and at their end; secret-shaped strings are redacted on the client.
- Gateway refuses: unknown device (401), a message without a session id, an
unregistered session under AGENTHUB_REMOTE_GATE=block, a user over quota
(EQUOTA), a purged session (410). Device tokens 90 days, conversations 30.
- New alert rule type remote_unmonitored: a company-account session its device
never registered; a late registration clears the flag instead.
- The hub imports a session once per upload round (chunks carry `more`),
not once per chunk.
Artifacts:
- Share an artifact with chosen people (artifact_shares, PATCH sharedWith,
GET /api/artifacts/people) besides everyone; a "Shared with you" tab.
Only the owner or an admin sees or edits who it is shared with; audited.
- Folders: each account's own arrangement (agenthub.artifacts.folders pref).
- A claude.ai page keeps a copy viewable without the publishing account: its
HTML file (and supporting files) snapshotted at publish, or the copy Claude
saves for a Doc / Slides / Design in artifacts-export/ (a synchronous
PostToolUse hint hook, scripts/artifact-export-hint.js, admin switch),
attached to the page at Stop. Viewed in HtmlFrame's sandbox, Markdown, or
pdf.js (pdfjs-dist 6.4.299, legacy build, loaded only for a PDF).
Fixes:
- tmux: a cached /proc/<pid>/exe of an exited server falls back to PATH's
tmux (stop the last pane, start again within 10 s failed with ENOENT).
- shelves: a shelf restores beside changes in other files.
- chat: a finished reply ends the turn even with no hook at all.
- Settings → AI tools: profile list display tweaks.
Claude Code skips the Stop hook in roughly one turn in fifteen (only the
prompt-suggestion helper's SubagentStop arrives), so the main agent stayed
"working" until the idle Notification a minute later and the chat kept
showing Stop on a finished answer. The transcript's turn_duration entry is
now an end of turn too: one newer than the session's last prompt, read
while main still works with no tool open, lands it in Waiting like Stop.
The chat's Stop button now drops to Send only after every busy signal has
been quiet for 2.5 s (Stop pressed still drops at once), so a spinner line
that comes and goes while the CLI redraws no longer flips it back and forth,
and only the newest "working" read is applied.
24 fixed hours (reports, backups) are found by type-ahead and the webhook
providers are a short fixed list; a search box there is only noise.
Branches, models, AI proxies and webhook field options keep theirs.
Select opens with a focused search box when it has more than 8 options
(or searchable): typing narrows by label/value, ↑/↓ move, Enter picks,
Esc closes; role=combobox drives the listbox via aria-activedescendant,
and options may carry a group heading. The git bar's native branch
select (and the separate search box added for #88) is replaced by it,
remote branches under their own heading.
The status chips' row needs ~340 px; the window was 320 and its body's
overflow-y:auto made x scrollable too, so a horizontal scrollbar showed
and Clear alerts was cut. 22rem wide (capped to the viewport), x hidden.
Each question started a full AI session (a heavy process on a shared
server, and one of a member's 4). A click on the pet now opens its window
(PetPanel): its name (rename), a pat, and the status strip. PetChat, its
off-screen terminal, its AI hand-off and its strings are gone; the window
is where the pet's own care comes next.
A Search branches… box beside Switch branch narrows the local and remote
lists to names containing the text (case-insensitive). One match is
picked at once, Enter picks the first, Esc clears. The picked branch
stays listed, so the select never loses its value.
Closes#88
POST /api/lanes/ensure without a cwd defaulted to the dashboard's own
home, outside a member's folder, so every member's pet chat failed with
"a workspace must be inside your own folder" (EFOLDER). A member now
gets <their folder>/sagi, made as them (write-as mkdir) on first use.
Settings → AI tools → Tests on this server (admin, S3 only). Stored in
/opt/agenthub/app/member-policy.json, which member-guard.js reads as the
member at each Bash call — no restart; no file = blocked. GET/PUT
/api/ai-tools/member-policy, audited, 409 ENOS3 outside S3.
- tool-run (CLAUDE_CODE_SHELL_PREFIX in member panes) runs each command
Claude runs at oom_score_adj 1000 (session 300, Terminal tab 800,
dashboard/tmux -100), so an OOM inside the capped unit kills the test
run and tells Claude why, instead of killing the conversation.
- member-guard.js, a synchronous managed hook for ah-* accounts: denies
test runners and container commands from Claude's Bash, and refuses
the prompts of a session started by hand past MAX_AI_SESSIONS.
- ai-procs.js counts a member's top-level AI processes from /proc;
spawnRun refuses a new pane past the limit (429 ESESSIONLIMIT).
Needs `agenthub server install-system` once, then a restart.
On 11-02 open sessions plus test runs filled RAM and the 4 GB swap on a
slow disk; the box hung until a hard reset. Member panes now start at
nice 10, IO priority 7 and oom_score_adj 500 with Vitest capped at 2
workers by default, and the system unit caps its cgroup (server + every
pane) at 85% of RAM with 1G swap and OOMPolicy=continue, so the kernel
kills the biggest pane process instead of hanging the machine. Needs
`agenthub server install-system` once.
Reverts 21a042d, 0d3e92c, 0016b6a, c797e98, 03ebea1. Keeps the pet's
own-sessions scope, status bar mine=1, scroll speed and the health hover card.
The chat_* tables and <data dir>/chat/ files are left in place (unused).
- A group whose creator left or was deleted passes to its longest-standing
member (ensureOwner), so it can still be managed.
- Group changes (added / removed / renamed / left) are sent live as
chat_message and never count as unread, here or on the server.
- A disabled account is still named in old chats (people carries
`disabled`) and never offered for a new one; a deleted one reads
'Deleted user' instead of '?'.
- Load older keeps the message being read in place.
- With several tabs open only the first to see a message plays its sound /
system notification (agenthub.chat.notified).
- The people list is read again on New chat and on every room change.
- Uploads go to <data dir>/chat/.incoming/ (multer disk storage) and are
renamed into place — never held in memory; a refused one is removed.
- With the pet hidden, a chat button in the bottom-right corner (ChatDock,
server mode) opens the same window, unread count and new-message card
included.
Delete chat is the caller's alone: the history so far vanishes for them
(chat_members.cleared_id) and a direct chat leaves their list (hidden) until a
new message arrives; the others keep theirs. What EVERY member has deleted is
deleted for real (purge): messages up to the lowest cleared_id and their files
on disk, and a direct chat both people deleted, whole. Purge also runs when a
member leaves or is removed. A group's creator disbands it for everyone
(DELETE /api/chat/rooms/:id).
Seen: rooms carry every member's read mark (reads); a mark that moves is told
to the members (chat_read, exactAudience) and never passes the newest
message. Under your newest message: Seen / Seen by … / Seen by everyone.
A room open on a tab you come back to is marked read then.
Rail: a room — open, mark as read, mute / unmute notifications (per browser,
the unread count still shows), copy name, group settings, leave; the pet and
+ — chat with the pet, new chat, mark all chats as read. A message — copy
text, reply (quotes it into the box), message the sender privately (group),
delete your own (asks first). A file — open in a new tab, download, copy file
name. A group member — message privately, copy name, remove (the creator).
DELETE /api/chat/rooms/:id/messages/:messageId deletes the caller's OWN text
message and its files (403 for anyone else, an admin included) and tells the
members with a chat_deleted frame (exactAudience).
A message from someone else in a room not on screen raises a card on the
pet (sender · group, the text or its files; click opens the room, gone after
10 s), plays the notification sound when notifications are on, and while
the tab is hidden shows a local system notification — never through the
server's push relay, which would reach other users' devices.
Group settings scrolled both ways (the name field and Rename overflowed, the
add-people list scrolled inside the panel): one vertical scroll now, the
field shrinks, the list grows in place. Every chat list hides horizontal
overflow and a long word or link wraps.
Server mode: a rail beside the pet's chat lists the pet, then the user's
direct chats (one per pair) and groups with unread counts, and + for a new
chat. A room shows its messages (group senders named), loads older pages,
and sends text, pasted / dropped / attached files (<= 10 x 25 MB; images
inline). A group's creator renames it and adds or removes people; anyone may
leave. A blue dot on the pet counts unread messages.
Only a room's members read or write it, an admin included (roomFor → 404).
Files are stored under server-made names in <data dir>/chat/<room>/ and
served to members only under a sandbox CSP + nosniff. chat_message /
chat_room frames reach exactly the ids in `to` (scope.exactAudience).
New tables chat_rooms / chat_members / chat_messages / chat_files (additive).
An admin's status bar and the Dashboard's Needs-you strip counted every
user's waiting sessions; both now ask GET /api/sessions with mine=1, like
Today's spend already did.
An admin/analyst's pet counted every user's sessions (live, waiting, errors)
because their WebSocket carries everyone's. Server mode now marks their
session/agent/event frames `mine: true|false` (scope.broadcastAudience owners,
worked out once per broadcast), and GET /api/sessions?mine=1 lists only the
caller's own sessions (ownSessionWhere). The pet seeds from mine=1 and skips
frames marked mine: false. Developers and local mode are unchanged.
lib/wheelReports.ts SCROLL_SPEED = 0.7: a touchpad's travel is scaled, and a
wheel notch's reports are carried as a fraction (rounded, so the first notch
still moves; reset when the direction turns). xterm's own scrollback gets the
same scrollSensitivity.
System Health's Users · CPU / RAM card: each tile's hover card names the login
name; for an admin or analyst it adds role, $ spent today, workspaces,
sessions and last sign-in from GET /api/users (staff-only, re-read every 60 s).
A developer's card shows the login name alone and never calls /api/users.
Signing was account-wide (gpg.format/user.signingkey/commit.gpgsign in
~/.gitconfig), so every repository was signed. Now Settings → Git →
Commit signing lists the user's workspaces, each with its own key picker
("Don't sign" by default). PUT /api/me/git/signing/lane {laneId, key}
writes a small file of ours (lane-<id>.gitconfig beside the account's
keys; a member's through write-as) and includes it from ~/.gitconfig
with includeIf "gitdir:<the workspace's git dir>" and
"gitdir:<common dir>/worktrees/ah-<id>-*" (its sessions' worktrees) —
only that workspace signs, from the terminal, the Changes panel or an
agent, and nothing is written into the repository. The git dirs come
from git run in the workspace's folder as its account; a workspace that
runs as another account is EFOREIGN, a folder that is not a repo
ENOREPO. Stopping needs no workspace, so one removed from the dashboard
while it signed is still listed and can be turned off.
The card now lists the keys that can sign, each with Delete (confirm;
deleting a key stops every workspace signing with it), a way out of the
old account-wide setting, and Remove for a signing setup the user made
themselves (PUT /signing {clearForeign: true}: the three settings only,
never their key file). Importing a key no longer turns on signing.
Verified with real signatures: the picked workspace's commits are G,
another repo's and an unrelated worktree's N, a session worktree's G.
System Health shows every user's name and avatarAt to everyone, but the
picture route GET /api/users/:id/avatar sat under the staff-only /users
prefix, so a developer got 403 EADMIN and saw only initials. authGuard now
lets any signed-in account make that one read (EVERYONE_READS); every
other /users path stays staff-only, and the test checks both.
An upload through the File Explorer is now sent in 8 MB parts that the
server stages on the host (<data dir>/upload-staging/<user>/<id>/,
server/lib/upload-staging.js) and writes only once every byte is there,
through the same upload/uploadBatch code and guards (?staged=<id>). A
restart, an update or a crash pauses it: the browser waits up to 3 min,
asks how much arrived (GET /fs/upload-part) and sends the rest; the dock
says the dashboard is restarting meanwhile. A commit's answer is stored
and replayed, never written twice; a refused one (EEXISTS) keeps the
bytes for a retry with another conflict policy.
A file uploaded into a member's folder is now written to a hidden temp
name and moved onto its name only when whole: write-as cannot tell a
dropped connection from the end of a file, and `replace` left a truncated
file under the real name when the server died mid-upload.
New: GET|POST|DELETE /api/{lanes/:id,homes/:who}/fs/upload-part; 409
EOFFSET/EINCOMPLETE/EBUSY, 429 ETOOMANY. Verified on a sandbox server:
SIGTERM mid-upload of a 60 MB file in a real browser, resumed and
sha256-identical.
Settings → Git gains Commit signing: one picker signs every commit
(terminal, Changes panel, agents) with one of the account's keys —
gpg.format ssh, user.signingkey = the key's private file, commit.gpgsign
true in its own ~/.gitconfig; a signing key the user set up themselves is
replaced only after a confirm (EFOREIGNSIGN without `replace`). Import a key
you have takes a key file or paste (OpenSSH or PEM, ed25519 or RSA >= 2048)
into ~/.config/agenthub/ssh/ as a named key, written like a generated one
(member: write-as, 0600), and by default signs with it in the same step.
A passphrase key, a .pub, an ecdsa key, a mismatched paste and the same key
twice are refused; only the public half ever comes back. Deleting the
signing key stops signing so commits keep working.
The SSH tab is renamed "Git (GitHub, GitLab, Gitea, Backlog…)" with the
Gitea and Backlog steps, and the File Explorer's ESECRET toast now says
that top-level dot folders of a home are edited in a terminal, and where
the SSH keys live instead.
xterm.js sends one wheel report per event whatever its size and damps a
touchpad's pixel deltas x0.3, so a swipe crawled. With mouse tracking on,
TerminalView now sends the SGR reports itself (lib/wheelReports.ts): one
per 5 rows of travel (what one report scrolls in a Claude pane), the
remainder carried between events, at least one per wheel notch, at the
pointer's cell with xterm's modifier bits.
Fullscreen Claude Code scrolls one line per wheel report when its terminal
does not answer XTVERSION as xterm.js (behind tmux it never does), and
xterm.js sends one report per notch — so a notch moved a single line.
spawnRun now sets CLAUDE_CODE_SCROLL_SPEED=5 unless the dashboard's env
already sets it; settings.json env and /config Scroll speed still win.
YouTube, Google and login pages refuse framing and the browser draws a
broken-page icon; the hint lived only in the empty state. A page not on this
machine now gets a strip under the address bar with Open tab. The refused-
address toast says what is allowed in plain words.
The session panel's header becomes two tabs, Changes and Preview. Preview
shows a web page by address (Reload, Open in a browser tab, the workspace's
ports as chips, last address per workspace) in an iframe on the page's own
origin: http(s) only, no userinfo, never the dashboard's origin, no top
navigation. One rule for every link (lib/openInPreview.ts): a chat or
terminal link to localhost/127.0.0.1/0.0.0.0/[::1] opens in Preview while a
session panel answers and the dashboard is on loopback; anything else, or a
Ctrl/Cmd click, opens a browser tab as before.
The branch name was shrink-0, so a long one ran over the head sha and the
action icons. It now truncates after the commit subject (full name in the
tooltip). The armed per-file Discard is the trash icon plus a small red
word on a faint tint, sized like its neighbours, instead of a bordered chip.
A wide pane left the chat squeezed into a 768px column with empty space
either side, so a wide table was cut off. The message list, the prompt
box and the session replay now share max-w-5xl.