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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-14 16:04:03 +07:00
parent e94038481e
commit 22de5009ff
79 changed files with 4617 additions and 231 deletions
+130 -6
View File
@@ -885,6 +885,16 @@ export const api = {
method: "PATCH",
body: JSON.stringify({ ...body, currentPassword }),
}),
/**
* DELETE /api/users/:id — delete the user for good: logins, their
* workspaces' records and (one Linux user per account) their Linux
* account and whole home. Never yourself or the last admin.
*/
remove: (id: number, currentPassword: string) =>
request<{ user: AuthUser }>(`/users/${id}`, {
method: "DELETE",
body: JSON.stringify({ currentPassword }),
}),
/** POST /api/users/:id/password — a new password; that user's logins end. */
setPassword: (id: number, password: string, currentPassword: string) =>
request<{ user: AuthUser }>(`/users/${id}/password`, {
@@ -2545,8 +2555,7 @@ export const api = {
method: "POST",
body: JSON.stringify({ paths }),
}),
trash: (t: FsPlace) =>
request<{ items: FsTrashItem[]; keepDays?: number }>(fsUrl(t, "/trash")),
trash: (t: FsPlace) => request<{ items: FsTrashItem[]; keepDays?: number }>(fsUrl(t, "/trash")),
restore: (t: FsPlace, id: string) =>
request<{ path: string; laneId: number | null }>(fsUrl(t, "/trash/restore"), {
method: "POST",
@@ -2613,11 +2622,14 @@ export const api = {
/** The signed-in user's own git identity (`/api/me/git`, `server/routes/git-identity.js`). */
gitIdentity: {
get: () => request<GitIdentity>("/me/git"),
/** Make the SSH key; `replace` swaps an existing one for a new pair. */
generateKey: (replace = false) =>
/**
* Make the SSH key; `replace` swaps an existing one for a new pair.
* `type`: ed25519 (default) or rsa (RSA 4096 — AWS CodeCommit takes only RSA).
*/
generateKey: (replace = false, type: "ed25519" | "rsa" = "ed25519") =>
request<GitIdentity>("/me/git/ssh-key", {
method: "POST",
body: JSON.stringify({ replace }),
body: JSON.stringify({ replace, type }),
}),
removeKey: () => request<GitIdentity>("/me/git/ssh-key", { method: "DELETE" }),
saveHttps: (body: { host: string; username: string; password: string }) =>
@@ -2646,6 +2658,58 @@ export const api = {
),
remove: (id: number) => request<{ ok: true }>(`/snapshots/${id}`, { method: "DELETE" }),
},
/** The Artifacts hub (`/api/artifacts`, `server/lib/artifacts.js`). */
artifacts: {
list: (params: { scope?: "mine" | "hub" | "all"; kind?: ArtifactKind; q?: string } = {}) => {
const qs = new URLSearchParams();
if (params.scope) qs.set("scope", params.scope);
if (params.kind) qs.set("kind", params.kind);
if (params.q) qs.set("q", params.q);
const s = qs.toString();
return request<{ items: Artifact[] }>(`/artifacts${s ? `?${s}` : ""}`);
},
settings: () => request<ArtifactSettings>("/artifacts/settings"),
setInstallsEnabled: (installsEnabled: boolean) =>
request<{ installsEnabled: boolean }>("/artifacts/settings", {
method: "PUT",
body: JSON.stringify({ installsEnabled }),
}),
/** Documents a workspace's sessions wrote, to pin. */
candidates: (laneId: number) =>
request<{ items: ArtifactCandidate[] }>(`/artifacts/candidates?laneId=${laneId}`),
/** Skill folders the caller may share (their own, and the workspace's). */
skills: (laneId?: number) =>
request<{ items: { name: string; from: "user" | "workspace" }[] }>(
`/artifacts/skills${laneId ? `?laneId=${laneId}` : ""}`
),
addPage: (body: { url: string; title?: string; description?: string }) =>
request<{ item: Artifact }>("/artifacts/page", { method: "POST", body: JSON.stringify(body) }),
pinFile: (body: { laneId: number; path: string; title?: string; sessionId?: string }) =>
request<{ item: Artifact }>("/artifacts/file", { method: "POST", body: JSON.stringify(body) }),
addSkill: (body: { name: string; laneId?: number; title?: string; description?: string }) =>
request<{ item: Artifact }>("/artifacts/skill", { method: "POST", body: JSON.stringify(body) }),
addPlugin: (body: { key: string; market: string; title?: string; description?: string }) =>
request<{ item: Artifact }>("/artifacts/plugin", {
method: "POST",
body: JSON.stringify(body),
}),
update: (id: number, body: { title?: string; description?: string; shared?: boolean }) =>
request<{ item: Artifact }>(`/artifacts/${id}`, { method: "PATCH", body: JSON.stringify(body) }),
remove: (id: number) => request<{ ok: true }>(`/artifacts/${id}`, { method: "DELETE" }),
install: (id: number, replace = false) =>
request<{ installed: string; replaced?: boolean }>(`/artifacts/${id}/install`, {
method: "POST",
body: JSON.stringify({ replace }),
}),
/** One of the snapshot's own files, served sandboxed. */
rawUrl: (id: number, path?: string, download = false) => {
const qs = new URLSearchParams();
if (path) qs.set("path", path);
if (download) qs.set("download", "1");
const s = qs.toString();
return `${BASE}/artifacts/${id}/raw${s ? `?${s}` : ""}`;
},
},
/** The mascot's custom pets (`/api/pets`, `server/lib/pets.js`). */
pets: {
list: () => request<PetList>("/pets"),
@@ -3640,6 +3704,21 @@ export const api = {
method: "PUT",
body: JSON.stringify({ instructions }),
}),
/** POST /api/lanes/:id/sources/clone — clone a related repo BESIDE the
* workspace folder and add it as an extra folder (409 ECREDENTIALS /
* ESSHAUTH like {@link lanes.clone}). */
cloneFolder: (
id: number,
body: {
url: string;
name?: string;
credentials?: { username: string; password: string; remember?: boolean };
}
) =>
request<LaneSourcesView & { path: string }>(`/lanes/${id}/sources/clone`, {
method: "POST",
body: JSON.stringify(body),
}),
/** POST /api/lanes/:id/sources/folder — an extra folder the agent may use. */
addFolder: (id: number, path: string) =>
request<LaneSourcesView>(`/lanes/${id}/sources/folder`, {
@@ -3820,6 +3899,51 @@ function requestCodexBackupsHelper(params?: {
export type CcArtifactType =
"skills" | "agents" | "commands" | "output-styles" | "memory" | "auto-memory";
export type ArtifactKind = "page" | "file" | "skill" | "plugin";
/** One row of the Artifacts hub (`server/lib/artifacts.js` `view`). */
export interface Artifact {
id: number;
kind: ArtifactKind;
title: string;
description: string;
ownerId: number | null;
owner: string | null;
mine: boolean;
canChange: boolean;
laneId: number | null;
sessionId: string | null;
/** page: the file it was published from; file: its workspace path;
* skill: its folder name; plugin: `name@marketplace`. */
source: string | null;
/** plugin: the marketplace source (owner/repo or a git URL). */
market: string | null;
/** page: the claude.ai link. */
url: string | null;
files: { path: string; size: number }[];
size: number | null;
shared: boolean;
sharedAt: string | null;
installs: number;
createdAt: string;
updatedAt: string;
}
export interface ArtifactCandidate {
path: string;
sessionId: string;
at: string;
exists: boolean;
pinned: boolean;
}
export interface ArtifactSettings {
serverMode: boolean;
canShare: boolean;
installsEnabled: boolean;
canManage: boolean;
}
/** Body for PUT /api/cc-config/file - create or overwrite one artifact. */
export interface CcWriteArgs {
// "auto-memory" targets a per-project memory file and requires `project`.
@@ -3950,7 +4074,7 @@ export interface GitIdentity {
account: "member" | "host";
/** False for the host's identity seen by a non-admin. */
writable: boolean;
ssh: { publicKey: string; fingerprint: string } | null;
ssh: { type?: "ed25519" | "rsa"; publicKey: string; fingerprint: string } | null;
/** The account's commit author (`git config --global`); null fields = unset. */
author: { name: string | null; email: string | null };
https: {
+37
View File
@@ -64,6 +64,43 @@ export function droppedAttachments(dt: DataTransfer | null | undefined): Attachm
}
}
/** A drop that carries no file and none of ours, only text — a file manager
* or an editor (VS Code) that hands over `file://` URIs, a selection, a
* link: the local `file://` URIs as `@path` attachments, else the text to
* insert, as a terminal pastes it. */
export function droppedText(
dt: DataTransfer | null | undefined
): { attachments: Attachment[] } | { text: string } | null {
try {
const paths = (dt?.getData("text/uri-list") ?? "")
.split(/\r?\n/)
.filter((l) => l && !l.startsWith("#"))
.map((l) => {
try {
const u = new URL(l.trim());
return u.protocol === "file:" && (!u.host || u.host === "localhost")
? decodeURIComponent(u.pathname)
: null;
} catch {
return null;
}
});
if (paths.length && paths.every(Boolean)) {
return {
attachments: (paths as string[]).map((p) => ({
// A space would end the mention: Claude Code reads `@"…"` whole.
path: /\s/.test(p) ? `@"${p}"` : `@${p}`,
name: p.replace(/\/$/, "").split("/").pop() || p,
})),
};
}
const text = dt?.getData("text/plain") ?? "";
return text.trim() ? { text } : null;
} catch {
return null;
}
}
export const SKILL_MIME = "application/x-agenthub-skill";
// A skill's name as Claude Code types it: `name` or `plugin:name`.
const SKILL_RE = /^[\w.-]+(?::[\w.-]+)?$/;
+20
View File
@@ -0,0 +1,20 @@
/**
* @file resolvePath.ts — resolves a path a chat reply names against the pane's
* folder: an absolute one (POSIX, `~`-less, or `C:\…`) is kept, a relative one
* (`server/lib/x.js`, `./a`, `../b`) is joined to `cwd` with `.`/`..` folded,
* so the preview and the Changes panel see the same absolute path the agent meant.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/** `path` as an absolute path; relative ones resolve against `cwd`. */
export function resolveIn(cwd: string, path: string): string {
if (/^(?:\/|[A-Za-z]:[\\/]|\\\\)/.test(path) || !cwd) return path;
const parts = cwd.replace(/[\\/]+$/, "").split(/[\\/]/);
for (const seg of path.split(/[\\/]/)) {
if (seg === "" || seg === ".") continue;
if (seg === "..") {
if (parts.length > 1) parts.pop();
} else parts.push(seg);
}
return parts.join("/") || "/";
}
+10 -1
View File
@@ -34,6 +34,8 @@ export const SETTINGS_INDEX: {
to?: string;
/** Server mode: a control only an admin sees, kept out of anyone else's search. */
adminOnly?: boolean;
/** Hidden in local mode (AgentHub's own git logins), so kept out of its search. */
serverOnly?: boolean;
}[] = [
{
section: "account",
@@ -330,10 +332,17 @@ export const SETTINGS_INDEX: {
{ section: "agents/*", titleKey: "toolTab.defaultModel", keywords: ["model", "config"] },
{ section: "ai-proxy", titleKey: "aiProxy.title", keywords: ["router", "endpoint", "key"] },
{ section: "pricing", titleKey: "pricing.title", keywords: ["cost", "price", "tokens"] },
{ section: "git", titleKey: "gitCreds.title", adminOnly: true, keywords: ["login", "token"] },
{
section: "git",
titleKey: "gitCreds.title",
adminOnly: true,
serverOnly: true,
keywords: ["login", "token"],
},
{
section: "git",
titleKey: "gitIdentity.title",
serverOnly: true,
keywords: ["ssh", "key", "clone", "https"],
to: "#git-identity",
},
+3 -1
View File
@@ -1925,7 +1925,9 @@ export interface WSMessage {
/** A lane's profile hook exited, with its status code. */
| "lane_hook_result"
/** A pane ended; refetch GET /api/run/interrupted (no payload). */
| "runs_interrupted";
| "runs_interrupted"
/** An artifact was added, changed, shared or deleted; refetch the list. */
| "artifact_changed";
/** The message body, whose concrete shape is selected by `type` above. */
data:
| Session