chore: v2.8.71 — remote clients (company hub), artifacts shared with chosen people, folders and viewable copies, fixes

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.
This commit is contained in:
2026-11-04 15:16:49 +07:00
parent 3daf7ed4cb
commit c946b73d11
95 changed files with 7918 additions and 340 deletions
+132 -1
View File
@@ -3601,7 +3601,11 @@ export const api = {
/** The Artifacts hub (`/api/artifacts`, `server/lib/artifacts.js`). */
artifacts: {
list: (
params: { scope?: "mine" | "hub" | "all" | "unowned"; kind?: ArtifactKind; q?: string } = {}
params: {
scope?: "mine" | "hub" | "withme" | "all" | "unowned";
kind?: ArtifactKind;
q?: string;
} = {}
) => {
const qs = new URLSearchParams();
if (params.scope) qs.set("scope", params.scope);
@@ -3611,6 +3615,17 @@ export const api = {
return request<{ items: Artifact[] }>(`/artifacts${s ? `?${s}` : ""}`);
},
settings: () => request<ArtifactSettings>("/artifacts/settings"),
setExportsEnabled: (exportsEnabled: boolean) =>
request<{ installsEnabled: boolean; exportsEnabled: boolean }>("/artifacts/settings", {
method: "PUT",
body: JSON.stringify({ exportsEnabled }),
}),
/** A page's viewable copy, taken now from the file it was published from (if still there). */
copy: (id: number) =>
request<{ item: Artifact }>(`/artifacts/${id}/copy`, { method: "POST", body: "{}" }),
/** Server mode: who the caller may share with — names only. */
people: () =>
request<{ people: { id: number; username: string; name: string }[] }>("/artifacts/people"),
setInstallsEnabled: (installsEnabled: boolean) =>
request<{ installsEnabled: boolean }>("/artifacts/settings", {
method: "PUT",
@@ -3697,6 +3712,8 @@ export const api = {
title?: string;
description?: string;
shared?: boolean;
/** Server mode: the user ids it is shared with by name (replaces the list). */
sharedWith?: number[];
/** plugin only: `name@marketplace`. */
source?: string;
/** plugin only: the marketplace source. */
@@ -3846,6 +3863,55 @@ export const api = {
*
* No response here ever carries an API key — a saved profile reports only
* `hasKey`. */
/** A LOCAL AgentHub's link to the company hub (server/routes/remote-client.js). */
remoteClient: {
/** GET /api/remote-client - enrollment and what was sent; never the token. */
status: () => request<RemoteClientStatus>("/remote-client"),
/** POST /api/remote-client/login - the password goes to the hub once, not kept. */
login: (body: { hub: string; username: string; password: string; deviceName?: string }) =>
request<RemoteClientStatus>("/remote-client/login", {
method: "POST",
body: JSON.stringify(body),
}),
/** POST /api/remote-client/logout - revoke on the hub, forget the token here. */
logout: () =>
request<{ ok: true; wasEnrolled: boolean; revokedOnHub?: boolean }>("/remote-client/logout", {
method: "POST",
}),
/** POST /api/remote-client/upload - send the Company-account sessions' new lines now. */
upload: () =>
request<{
sessions: number;
bytes: number;
errors: { sessionId: string; message: string }[];
}>("/remote-client/upload", { method: "POST" }),
},
/** The hub's remote-client views (server mode, server/hub/). */
remoteHub: {
/** GET /api/remote/devices - your machines; staff may name `user`. */
devices: (user?: number) =>
request<{ devices: RemoteDevice[] }>(`/remote/devices${user ? `?user=${user}` : ""}`),
/** POST /api/remote/devices/:id/revoke - your own password again. */
revoke: (id: number, password: string) =>
request<{ ok: true; device: RemoteDevice }>(`/remote/devices/${id}/revoke`, {
method: "POST",
body: JSON.stringify({ password }),
}),
/** GET /api/remote/sessions - Company-account sessions (device › folder). */
sessions: (user?: number) =>
request<{ sessions: RemoteSessionRow[] }>(`/remote/sessions${user ? `?user=${user}` : ""}`),
/** GET /api/remote/audit - who opened your sessions. */
audit: () =>
request<{
entries: {
id: number;
at: string;
sessionId: string;
reader: string | null;
readerDisplay: string | null;
}[];
}>("/remote/audit"),
},
aiProxies: {
/** GET /api/ai-proxies - every profile, the built-in `native` one first. */
list: () => request<{ items: AiProxy[] }>("/ai-proxies"),
@@ -5222,6 +5288,10 @@ export interface Artifact {
size: number | null;
shared: boolean;
sharedAt: string | null;
/** Server mode: the people it is shared with by name (only its owner or an admin gets the list). */
sharedWith?: { id: number; name: string }[];
/** Server mode: shared with the caller by name. */
sharedWithMe?: boolean;
installs: number;
/** False once its owner removed it from their library while shared: Hub only. */
inLibrary: boolean;
@@ -5302,6 +5372,8 @@ export interface ArtifactSettings {
serverMode: boolean;
canShare: boolean;
installsEnabled: boolean;
/** Claude panes are asked to save a viewable copy of a Doc / Slides / Design they publish. */
exportsEnabled?: boolean;
canManage: boolean;
}
@@ -5614,6 +5686,9 @@ export interface AiProxy {
/** Identifier, derived from the label. Stable: a session's run row stores it. */
name: string;
label: string;
/** The remote client's Company account (`agenthub.company`) — the hub's gateway,
* added by the client on an enrolled machine; never a saved profile. */
company?: boolean;
/** True only for the read-only `native` profile (the vendor's own API). */
builtin: boolean;
/** "shared": the host's (an admin's, in server mode), usable by everyone;
@@ -6805,3 +6880,59 @@ export interface PetList {
featured: FeaturedPet[];
sheet: { width: number; height: number; cellWidth: number; cellHeight: number };
}
/** An enrolled machine on the hub (server/hub/devices.js) — never its token. */
export interface RemoteDevice {
id: number;
userId: number;
name: string;
createdAt: string;
expiresAt: string;
lastSeenAt: string | null;
lastIp: string | null;
revokedAt: string | null;
active: boolean;
}
/** A Company-account session a device registered (server/hub/remote-sessions.js). */
export interface RemoteSessionRow {
sessionId: string;
deviceId: number;
deviceName: string;
userId: number;
cwd: string;
tool: string;
account: string | null;
registeredAt: string;
endedAt: string | null;
lastRequestAt: string | null;
requests: number;
inputTokens: number;
outputTokens: number;
cacheReadTokens: number;
cacheWriteTokens: number;
lastUploadAt: string | null;
purgedAt: string | null;
unmonitored: number;
title: string | null;
}
/** GET /api/remote-client — this local machine's link to the hub. */
export interface RemoteClientStatus {
enrolled: boolean;
expired?: boolean;
hub?: string;
user?: { id: number; username: string; role: string } | null;
device?: { id: number; name: string; expiresAt: string } | null;
enrolledAt?: string | null;
retainDays?: number | null;
uploads?: {
sessionId: string;
bytes: number;
lastSentAt: string | null;
ended: number;
title: string | null;
cwd: string | null;
startedAt: string | null;
}[];
}
+150
View File
@@ -0,0 +1,150 @@
/**
* @file artifactFolders — the Artifacts page's user-made folders ("Slides",
* "Client A", …): their names and order, and which folder each artifact sits
* in. One JSON value under `agenthub.artifacts.folders`, an account preference
* key (`lib/userPrefs.ts`, `server/routes/me.js`), so the folders are kept per
* ACCOUNT on the server and follow it to any browser. A viewer's arrangement,
* not an artifact fact: nothing about an artifact changes, other users never
* see these folders, and deleting a folder only takes its artifacts out of it.
* Same store shape as `workspaceGroups.ts`.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useSyncExternalStore } from "react";
import { PREFS_EVENT } from "./workspaceGroups";
export const ARTIFACT_FOLDERS_KEY = "agenthub.artifacts.folders";
export const MAX_FOLDER_NAME = 60;
export interface ArtifactFolder {
id: string;
name: string;
}
export interface ArtifactFolders {
/** In display order. */
folders: ArtifactFolder[];
/** Artifact id → folder id. An artifact absent here is in no folder. */
of: Record<string, string>;
}
const EMPTY: ArtifactFolders = { folders: [], of: {} };
const listeners = new Set<() => void>();
let cache: { raw: string | null; value: ArtifactFolders } = { raw: null, value: EMPTY };
function readRaw(): string | null {
try {
return localStorage.getItem(ARTIFACT_FOLDERS_KEY);
} catch {
return null;
}
}
function parse(raw: string | null): ArtifactFolders {
if (!raw) return EMPTY;
try {
const v = JSON.parse(raw) as Partial<ArtifactFolders>;
const folders = (Array.isArray(v.folders) ? v.folders : []).filter(
(f): f is ArtifactFolder =>
!!f && typeof f.id === "string" && !!f.id && typeof f.name === "string"
);
const ids = new Set(folders.map((f) => f.id));
const of: Record<string, string> = {};
if (v.of && typeof v.of === "object") {
for (const [item, fid] of Object.entries(v.of)) {
if (typeof fid === "string" && ids.has(fid)) of[item] = fid;
}
}
return { folders, of };
} catch {
return EMPTY;
}
}
export function readArtifactFolders(): ArtifactFolders {
const raw = readRaw();
if (raw === cache.raw) return cache.value;
cache = { raw, value: parse(raw) };
return cache.value;
}
function write(next: ArtifactFolders): void {
try {
localStorage.setItem(ARTIFACT_FOLDERS_KEY, JSON.stringify(next));
} catch {
/* disabled storage: the folders live until the page closes */
cache = { raw: JSON.stringify(next), value: next };
}
listeners.forEach((l) => l());
}
function update(fn: (cur: ArtifactFolders) => ArtifactFolders): void {
write(fn(readArtifactFolders()));
}
const cleanName = (name: string) => name.trim().slice(0, MAX_FOLDER_NAME);
/** A new folder at the end, holding `itemIds` (moved out of their old folder). Returns its id, or null for a blank name. */
export function createFolder(name: string, itemIds: number[] = []): string | null {
const clean = cleanName(name);
if (!clean) return null;
const id = `f${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
update((cur) => {
const of = { ...cur.of };
for (const item of itemIds) of[item] = id;
return { folders: [...cur.folders, { id, name: clean }], of };
});
return id;
}
/** Rename; a blank name keeps the old one. */
export function renameFolder(id: string, name: string): void {
const clean = cleanName(name);
if (!clean) return;
update((cur) => ({
...cur,
folders: cur.folders.map((f) => (f.id === id ? { ...f, name: clean } : f)),
}));
}
/** Delete the folder; its artifacts are only taken out of it. */
export function deleteFolder(id: string): void {
update((cur) => ({
folders: cur.folders.filter((f) => f.id !== id),
of: Object.fromEntries(Object.entries(cur.of).filter(([, fid]) => fid !== id)),
}));
}
/** Put an artifact in a folder, or (null) take it out of every folder. */
export function moveToFolder(itemId: number, folderId: string | null): void {
update((cur) => {
const of = { ...cur.of };
if (folderId && cur.folders.some((f) => f.id === folderId)) of[itemId] = folderId;
else delete of[itemId];
return { ...cur, of };
});
}
/** The folder an artifact is in, or null. */
export function folderOf(value: ArtifactFolders, itemId: number): ArtifactFolder | null {
const fid = value.of[itemId];
return (fid && value.folders.find((f) => f.id === fid)) || null;
}
function subscribe(l: () => void) {
listeners.add(l);
const onStorage = (e: StorageEvent) => {
if (e.key === ARTIFACT_FOLDERS_KEY || e.key === null) l();
};
window.addEventListener("storage", onStorage);
window.addEventListener(PREFS_EVENT, l);
return () => {
listeners.delete(l);
window.removeEventListener("storage", onStorage);
window.removeEventListener(PREFS_EVENT, l);
};
}
export function useArtifactFolders(): ArtifactFolders {
return useSyncExternalStore(subscribe, readArtifactFolders);
}
+42
View File
@@ -0,0 +1,42 @@
/**
* @file hiddenProfilesStorage.ts
* @description Claude login profiles this browser leaves out of Settings →
* AI tools → Profiles, kept in localStorage with the same read/validate/
* fallback convention as laneOrderStorage.ts. A viewer preference only: the
* profile files, the API and the database are untouched, and another browser
* still lists every profile.
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const STORAGE_KEY = "agenthub.cc.hiddenProfiles";
/** A profile's stable key: its account (survives a rename), else its file name. */
export function profileKey(p: { accountUuid: string | null; name?: string }): string {
return p.accountUuid || p.name || "";
}
export function readHiddenProfiles(): string[] {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return [];
const parsed: unknown = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return parsed.filter((k): k is string => typeof k === "string" && k.length > 0);
} catch {
return [];
}
}
export function writeHiddenProfiles(keys: string[]): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(keys));
} catch {
/* ignore quota / disabled storage */
}
}
/** `keys` with `key` added when absent, removed when present. */
export function toggleHidden(keys: string[], key: string): string[] {
return keys.includes(key) ? keys.filter((k) => k !== key) : [...keys, key];
}
+4 -1
View File
@@ -1573,7 +1573,10 @@ export type AlertRuleType =
| "status_duration"
| "token_threshold"
| "cost_threshold"
| "budget";
| "budget"
/** Server mode: a user's machine used the company account for a session it
* never registered with the hub (no config). */
| "remote_unmonitored";
/**
* Rule-type-specific settings for an {@link AlertRule}. Which fields apply
+1
View File
@@ -47,6 +47,7 @@ export const PREF_KEYS = [
"agenthub.markdown.view",
"agenthub.workspace.groups",
"agenthub.changes.innerRepos",
"agenthub.artifacts.folders",
] as const;
const SYNCED = new Set<string>(PREF_KEYS);
const PUSH_DELAY_MS = 400;