fix: per-user Today, native paste and drop on a local machine

- Today's spend in the status bar and the Claude Code statusline is the
  caller's own (`GET /api/pricing/cost?mine=1`, scope.ownSessionWhere), not
  every user's sum for an admin or analyst.
- A screenshot pasted into a local claude pane is sent as Claude Code's own
  image-paste key: Alt+V on Windows (Ctrl+V did nothing there), ^V elsewhere,
  and only when the machine can read its clipboard (wl-paste/xclip + a
  display on Linux), else it is uploaded (GET /api/run/native-paste {key}).
- A file manager's files pasted or dropped on a local browser go in by their
  own paths, read from the OS clipboard or the file manager's selection
  (server/lib/file-manager.js, POST /api/run/clipboard-files and
  /selected-files): no upload, no size limit. A copied image file is pasted
  by path, text that comes with a picture of it (spreadsheet cells) is
  pasted as text, Windows paths go in as `C:/…` mentions.
- Tab titles for Snapshot, Artifacts, Files and Administration.
- An npm tool AgentHub installed under ~/.local is updated there, not by its
  own updater into npm's global prefix.
This commit is contained in:
2026-10-26 11:18:05 +07:00
parent c59e196e41
commit f2f1cd9b6c
33 changed files with 1046 additions and 60 deletions
+33 -1
View File
@@ -1023,6 +1023,13 @@ export function gitTarget(
return session ? { lane: laneId, session } : laneId;
}
/** A file the dashboard machine's file manager holds, by its own path. */
export interface FileManagerFile {
path: string;
size: number | null;
dir: boolean;
}
export const api = {
// ───────────────────────── Server mode login (routes/auth.js) ─────────────────────────
auth: {
@@ -2152,7 +2159,9 @@ export const api = {
*
* @returns {@link CostResult} — the aggregate cost breakdown across sessions.
*/
totalCost: (opts: { lane?: number; date?: string; tool?: string; range?: DateRange } = {}) => {
totalCost: (
opts: { lane?: number; date?: string; tool?: string; range?: DateRange; mine?: boolean } = {}
) => {
// Scope the aggregate to the active data-scope, exactly like the sessions /
// stats / analytics endpoints — otherwise switching the Data scope selector
// left the Dashboard "total cost" showing the un-narrowed global total.
@@ -2162,6 +2171,8 @@ export const api = {
// `date` (local YYYY-MM-DD): only that day's usage, breakdown included.
if (opts.date) qs.set("date", opts.date);
if (opts.tool) qs.set("tool", opts.tool);
// `mine`: only the caller's own sessions, even for an admin/analyst (server mode).
if (opts.mine) qs.set("mine", "1");
// `range`: total, breakdown and by_tool for those local days only.
applyRange(qs, opts.range);
applyScope(qs);
@@ -3350,6 +3361,27 @@ export const api = {
method: "POST",
body: JSON.stringify({ paths }),
}),
/** POST /api/run/clipboard-files — the files a file manager's Copy put on
* the dashboard machine's clipboard, by their own paths (`size` null for a
* folder); 403 EREMOTE unless local mode and a loopback browser. */
clipboardFiles: () =>
request<{ files: FileManagerFile[] }>("/run/clipboard-files", {
method: "POST",
body: "{}",
}),
/** POST /api/run/selected-files — the files selected in that machine's
* file manager (Explorer, Finder): what a drag from it carries. Same
* shape and refusal as `clipboardFiles`. */
selectedFiles: () =>
request<{ files: FileManagerFile[] }>("/run/selected-files", {
method: "POST",
body: "{}",
}),
/** GET /api/run/native-paste — whether a `claude` pane can read this
* machine's clipboard itself (a reader installed, a display reachable);
* false for a remote browser and in server mode. `key`: the chord
* `claude` reads an image with on that machine (Alt+V on Windows). */
nativePaste: () => request<{ native: boolean; key?: "ctrl+v" | "alt+v" }>("/run/native-paste"),
/** 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) }),
+39
View File
@@ -8,9 +8,15 @@
* uploaded or copied. Off, the image is uploaded like a remote browser's. An
* SSH tunnel's browser also reaches the dashboard by loopback, and its `^V`
* would read the SERVER's clipboard — that is what turning it off is for.
* The server must also be able to read its clipboard (`nativePasteWorks`: on
* Linux `wl-paste`/`xclip` and a display) — else the `^V` pasted nothing.
* On Windows Claude Code binds its image paste to Alt+V, not Ctrl+V
* (`nativePasteKey`): a `^V` there was silently ignored.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { api } from "./api";
export const NATIVE_PASTE_KEY = "agenthub.terminal.nativePaste";
/** True unless this browser turned native image paste off. */
@@ -30,3 +36,36 @@ export function writeNativePaste(on: boolean) {
/* storage blocked — the default */
}
}
let works: boolean | null = null;
let asked: Promise<void> | null = null;
// Claude Code's image-paste chord on the dashboard's machine: Ctrl+V, but
// Alt+V on Windows, where a ^V reaches no binding and pastes nothing.
let key = "\x16";
/** Whether the dashboard's machine can hand a pane its clipboard, as last
* answered — false until it has (an upload then, which always works). The
* first call asks the server once per page. */
export function nativePasteWorks(): boolean {
asked ??= api.run
.nativePaste()
.then((r) => {
works = r.native === true;
key = r.key === "alt+v" ? "\x1bv" : "\x16";
})
.catch(() => void (works = false));
return works === true;
}
/** The bytes that make the pane's `claude` read its clipboard: ^V, or ESC v
* (Alt+V) on a Windows dashboard. */
export function nativePasteKey(): string {
return key;
}
/** Test seam: forget the server's answer. */
export function __resetNativePaste() {
works = null;
asked = null;
key = "\x16";
}
+54 -3
View File
@@ -9,8 +9,10 @@
* dashboard's own machine attaches by path instead of uploading
* (`localCandidates` — a drag's uri-list, or the plain-text paths a pasted
* file-manager copy carries —, checked by the server in `sameFilesHere` —
* `POST /api/run/local-paths` —, `localAttachment`). An attachment is a path
* sent with the prompt; that check is the one call here to the server.
* `POST /api/run/local-paths` —, `localAttachment`), and the pasted or
* dropped files that machine's file manager holds (`fileManagerPaths`). An
* attachment is a path sent with the prompt; those checks are the calls here
* to the server.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -144,6 +146,53 @@ export async function sameFilesHere(paths: string[], files: File[]): Promise<boo
}
}
/** A pasted BITMAP (a screenshot), not a copied image FILE: browsers name the
* clipboard's picture `image.png`, while a file manager's copy keeps its own
* name. Only a bitmap goes to `claude`'s own image paste — a copied file is
* pasted by its path, as a native terminal does (`claude` finds no picture
* on a clipboard that holds a file) — and a bitmap that comes WITH text (a
* spreadsheet's cells, a page's selection) is the text's picture: a
* terminal pastes the text. */
export function isClipboardBitmap(f: File): boolean {
return f.type.startsWith("image/") && f.name === "image.png";
}
/** The pasted or dropped `files` by their own paths on the dashboard's
* machine, as its file manager holds them — a paste on its OS clipboard, a
* drop as its selection, which is what a drag from it carries
* (`POST /api/run/clipboard-files` / `selected-files`): a native terminal's
* paths, and the one way past the upload limit. Null when this is not a
* loopback browser, on a refusal (server mode), or when any file is not
* held under the same name and size (an SSH tunnel's browser pastes
* ANOTHER machine's files). Paths come back in `files`' order. */
export async function fileManagerPaths(
files: File[],
from: "paste" | "drop"
): Promise<string[] | null> {
if (!isLocalBrowser() || files.length === 0) return null;
try {
const { files: held } = await (from === "paste"
? api.run.clipboardFiles()
: api.run.selectedFiles());
const base = (p: string) =>
p
.replace(/[\\/]+$/, "")
.split(/[\\/]/)
.pop() ?? "";
const left = [...held];
const paths: string[] = [];
for (const f of files) {
const i = left.findIndex((h) => base(h.path) === f.name && (h.dir || h.size === f.size));
if (i < 0) return null;
paths.push(left[i]!.path.replace(/(.)[\\/]+$/, "$1"));
left.splice(i, 1);
}
return paths;
} catch {
return null;
}
}
/** The local files a drop or a paste names as `file://` URIs (a file
* manager's, an editor's), as absolute paths — or null when it names none,
* or anything that is not one. `file:///C:/x` is `C:/x`. */
@@ -169,9 +218,11 @@ export function droppedFilePaths(dt: DataTransfer | null | undefined): string[]
/** A file on this machine, by its absolute path, as an attachment. */
export function localAttachment(path: string): Attachment {
// A Windows path in `/` form, like `claudeMention` (Windows takes either).
const p = /^[A-Za-z]:\\/.test(path) ? path.replace(/\\/g, "/") : path;
return {
// A space would end the mention: Claude Code reads `@"…"` whole.
path: /\s/.test(path) ? `@"${path}"` : `@${path}`,
path: /\s/.test(p) ? `@"${p}"` : `@${p}`,
name:
path
.replace(/[\\/]$/, "")