chore: v2.8.13 — Artifacts reinstall/edit/installs, agent steps in chat, pick a local file to attach, Windows paths as links

- Chat: an Agent step shows its task, status and tool calls and opens its conversation in place; Windows paths (E:\…, C:/…) in replies open as files
- Prompt: attach a file on this computer by path (native picker), clearer too-large messages
- Artifacts: edit title/description and a skill's files (new snapshot), Reinstall, install history, Hub-only rows, skills installed into ~/.claude/skills and ~/.agents/skills
- Files: batch upload of small files in one extract
- AI Tools: npm install falls back to --prefix ~/.local when the global prefix is not writable
This commit is contained in:
2026-10-20 16:44:55 +07:00
parent 49f87c4da3
commit 0547488f13
59 changed files with 4285 additions and 690 deletions
+105 -35
View File
@@ -796,6 +796,8 @@ async function send<T>(path: string, options?: RequestInit): Promise<T> {
runId: body?.error?.run_id as string | undefined,
// EEXISTS from the File Explorer: the names already taken.
names: body?.error?.names as string[] | undefined,
// ETOOBIG from a prompt drop: the server's own limit.
maxBytes: body?.error?.max_bytes as number | undefined,
});
}
// A 200 that is not JSON — the SPA's index.html from a proxy or a dev server
@@ -861,6 +863,44 @@ function fsUrl(place: FsPlace, sub: string, q?: URLSearchParams): string {
return gitUrl(place, sub, q).replace(/^(\/lanes\/\d+)\/git/, "$1/fs");
}
/**
* POST `payload` (a file or a batch) as a raw stream to `url`, reporting upload
* progress. Rejects like {@link request}: `code`, and `names` on EEXISTS.
*/
const sendRaw = <T>(url: string, payload: Blob, onProgress?: (sent: number) => void) =>
new Promise<T>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("POST", `${BASE}${url}`);
xhr.setRequestHeader("Content-Type", "application/octet-stream");
const token = dashboardToken();
if (token) xhr.setRequestHeader("x-dashboard-token", token);
xhr.upload.onprogress = (e) => onProgress?.(e.loaded);
xhr.onerror = () => reject(Object.assign(new Error("network error"), { code: "ENET" }));
xhr.onload = () => {
let body: { error?: { code?: string; message?: string; names?: string[] } } = {};
try {
body = JSON.parse(xhr.responseText);
} catch {
/* not JSON */
}
if (xhr.status >= 200 && xhr.status < 300) {
resolve(body as T);
return;
}
if (xhr.status === 401 && body.error?.code === "EAUTH") {
window.dispatchEvent(new Event(AUTH_REQUIRED_EVENT));
}
reject(
Object.assign(new Error(body.error?.message || `HTTP ${xhr.status}`), {
code: body.error?.code,
status: xhr.status,
names: body.error?.names,
})
);
};
xhr.send(payload);
});
/** A home the File Explorer can open (`GET /api/homes`): `me`, or another user's for an admin. */
export interface FsHome {
id: string;
@@ -2764,40 +2804,45 @@ export const api = {
conflict: FsConflict = "fail",
onProgress?: (sent: number) => void
) =>
new Promise<{ path: string; skipped?: boolean }>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("POST", `${BASE}${fsUrl(t, "/upload", new URLSearchParams({ path, conflict }))}`);
xhr.setRequestHeader("Content-Type", "application/octet-stream");
const token = dashboardToken();
if (token) xhr.setRequestHeader("x-dashboard-token", token);
xhr.upload.onprogress = (e) => onProgress?.(e.loaded);
xhr.onerror = () => reject(Object.assign(new Error("network error"), { code: "ENET" }));
xhr.onload = () => {
let body: { error?: { code?: string; message?: string; names?: string[] } } & {
path?: string;
skipped?: boolean;
} = {};
try {
body = JSON.parse(xhr.responseText);
} catch {
/* not JSON */
}
if (xhr.status >= 200 && xhr.status < 300) {
resolve(body as { path: string; skipped?: boolean });
return;
}
if (xhr.status === 401 && body.error?.code === "EAUTH") {
window.dispatchEvent(new Event(AUTH_REQUIRED_EVENT));
}
reject(
Object.assign(new Error(body.error?.message || `HTTP ${xhr.status}`), {
code: body.error?.code,
status: xhr.status,
names: body.error?.names,
})
);
};
xhr.send(file);
sendRaw<{ path: string; skipped?: boolean }>(
fsUrl(t, "/upload", new URLSearchParams({ path, conflict })),
file,
onProgress
),
/**
* Many small files in ONE request, under the folder `dir` (each `rel`
* relative to it): a 4-byte length, the JSON file list, then the bytes.
* The Blob is only composed, never read into memory here.
*/
uploadBatch: (
t: FsPlace,
dir: string,
files: { file: Blob; rel: string }[],
conflict: FsConflict = "fail",
onProgress?: (sent: number) => void
) => {
const head = new TextEncoder().encode(
JSON.stringify({
files: files.map((f) => ({
path: f.rel,
size: f.file.size,
mtime: f.file instanceof File ? f.file.lastModified : undefined,
})),
})
);
const len = new Uint8Array(4);
new DataView(len.buffer).setUint32(0, head.length);
return sendRaw<{ saved: number; skipped: number; paths: string[] }>(
fsUrl(t, "/upload-batch", new URLSearchParams({ dir, conflict })),
new Blob([len, head, ...files.map((f) => f.file)], { type: "application/octet-stream" }),
onProgress
);
},
/** Before a batch: which of `paths` (under `dir`) are taken, and whether a batch can go there. */
uploadCheck: (t: FsPlace, dir: string, paths: string[]) =>
request<{ taken: string[]; batch: boolean }>(fsUrl(t, "/upload-check"), {
method: "POST",
body: JSON.stringify({ dir, paths }),
}),
},
/** The signed-in user's own git identity (`/api/me/git`, `server/routes/git-identity.js`). */
@@ -2945,18 +2990,24 @@ export const api = {
source?: string;
/** plugin only: the marketplace source. */
market?: string;
/** skill only: new contents, written as a new snapshot. */
skill?: { name: string; description: string; content: string };
}
) =>
request<{ item: Artifact }>(`/artifacts/${id}`, {
method: "PATCH",
body: JSON.stringify(body),
}),
remove: (id: number) => request<{ ok: true }>(`/artifacts/${id}`, { method: "DELETE" }),
/** `kept`: a shared one left your library but stays in the Hub. */
remove: (id: number) =>
request<{ ok: true; kept: boolean }>(`/artifacts/${id}`, { method: "DELETE" }),
install: (id: number, replace = false) =>
request<{ installed: string; replaced?: boolean }>(`/artifacts/${id}/install`, {
method: "POST",
body: JSON.stringify({ replace }),
}),
/** GET /api/artifacts/:id/installs — who installed it, when, and where. */
installs: (id: number) => request<ArtifactInstalls>(`/artifacts/${id}/installs`),
/** One of the snapshot's own files, served sandboxed. */
rawUrl: (id: number, path?: string, download = false) => {
const qs = new URLSearchParams();
@@ -3134,6 +3185,14 @@ export const api = {
headers: { "Content-Type": "application/octet-stream" },
});
},
/** POST /api/run/local-paths — each absolute path's size when it is a
* regular file on the dashboard's machine, else null; 403 EREMOTE unless
* the browser is on that machine (and never in server mode). */
localPaths: (paths: string[]) =>
request<{ sizes: (number | null)[] }>("/run/local-paths", {
method: "POST",
body: JSON.stringify({ paths }),
}),
/** POST /api/run - start (or adopt, if already live) a lane's terminal run. */
start: (args: RunStartArgs) =>
request<RunHandle>("/run", { method: "POST", body: JSON.stringify(args) }),
@@ -4203,10 +4262,21 @@ export interface Artifact {
shared: boolean;
sharedAt: string | null;
installs: number;
/** False once its owner removed it from their library while shared: Hub only. */
inLibrary: boolean;
/** List rows only: a skill/plugin already in every account the viewer's sessions run as. */
installed?: boolean;
createdAt: string;
updatedAt: string;
}
/** GET /api/artifacts/:id/installs. `paths` only for your own installs (or an admin). */
export interface ArtifactInstalls {
items: { user: string | null; mine: boolean; at: string; replaced: boolean; paths?: string[] }[];
/** Installs counted before the list was kept. */
unrecorded: number;
}
/** POST /api/artifacts/plugin/inspect — a marketplace and the plugins it lists. */
export interface MarketplaceInfo {
/** The source as stored (owner/repo, a git URL or a marketplace.json link). */