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:
+132
-1
@@ -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;
|
||||
}[];
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user