Compare commits

...

2 Commits

Author SHA1 Message Date
nntrivi2001 6b602c76b8 chore: v2.8.25 — per-user Today, Alt+V image paste on Windows, local paste/drop by path 2026-10-26 11:18:05 +07:00
nntrivi2001 f2f1cd9b6c 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.
2026-10-26 11:18:05 +07:00
37 changed files with 1051 additions and 65 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
# Claude Code Working Guide
_Last synced: v2.8.24 (2026-10-26) — OpenCode, Grok Build and the Kimi quota reader left; a shared skill follows its owner's folder (Update when an installed copy differs); a sub-agent's edits count in Edited N files._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session.
_Last synced: v2.8.25 (2026-10-26) — Today's spend is per user (`?mine=1`); a local terminal pastes images with the OS's own key (Alt+V on Windows) and pastes/drops a file manager's files by their own paths (`server/lib/file-manager.js`)._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session.
## Project mission
- Maintain a reliable local-first dashboard for Claude Code session monitoring.
+8
View File
@@ -24,10 +24,18 @@ export function DocumentTitle() {
title = `${t("sessions")} · ${sessionId.slice(0, 8)}`;
} else if (path.startsWith("/sessions")) {
title = t("sessions");
} else if (path.startsWith("/snapshot/")) {
title = t("snapshot");
} else if (path.startsWith("/workspace")) {
title = t("workspace");
} else if (path.startsWith("/settings")) {
title = t("settings");
} else if (path.startsWith("/artifacts")) {
title = t("artifacts");
} else if (path.startsWith("/files")) {
title = t("files");
} else if (path.startsWith("/admin")) {
title = t("admin");
} else if (path !== "/") {
title = t("notFound");
}
+3 -2
View File
@@ -5,7 +5,8 @@
* — with a green dot while a newer release is out —, how many SESSIONS are
* running and how many need the user (`GET /api/sessions?status=active` and
* `?awaiting=1` — the Dashboard's Needs-you rule —, re-read on session
* messages), and today's spend — `GET /api/pricing/cost?date=<local date>` — and, on the
* messages), and today's spend — `GET /api/pricing/cost?date=<local date>&mine=1`, the
* user's OWN spend even for an admin — and, on the
* Workspace page, the open workspace's total (`?lane=` on the same call: the
* sessions it owns) and the shown session's (`GET /api/pricing/cost/:id` for
* the URL's `session`, or the focused pane's session from the page's run poll,
@@ -403,7 +404,7 @@ export function StatusBar({
const gen = costGen.current;
const live = () => gen === costGen.current;
api.pricing
.totalCost({ date: localDate() })
.totalCost({ date: localDate(), mine: true })
.then((r) => live() && setToday(r))
.catch(() => undefined);
api.budgets
@@ -34,5 +34,13 @@ describe("DocumentTitle", () => {
expect(titleFor("/")).toMatch(/^Dashboard · /);
expect(titleFor("/sessions")).toMatch(/^Sessions · /);
expect(titleFor("/settings")).toMatch(/^Settings · /);
expect(titleFor("/artifacts")).toMatch(/^Artifacts · /);
expect(titleFor("/files")).toMatch(/^Files · /);
expect(titleFor("/admin")).toMatch(/^Administration · /);
expect(titleFor("/snapshot/abc")).toMatch(/^Snapshot · /);
});
it("titles an unknown route as not found", () => {
expect(titleFor("/nope")).toMatch(/^Not found · /);
});
});
@@ -162,7 +162,7 @@ describe("StatusBar", () => {
const conn = screen.getByRole("button", { name: /Live/ });
expect(conn).toHaveTextContent(`v${__APP_VERSION__}`);
const cost = await screen.findByText("Today $1.23 (12.3M Tokens)");
expect(api.pricing.totalCost).toHaveBeenCalledWith({ date: today() });
expect(api.pricing.totalCost).toHaveBeenCalledWith({ date: today(), mine: true });
expect(cost).toHaveAttribute(
"title",
"Claude Opus 4.8: 12.0M · $1.20\nClaude Haiku 4.5: 300.0K · $0.03"
+29 -17
View File
@@ -12,7 +12,8 @@
* remembered for the + menu's Recent uploads), and a panel row (Files,
* Changes, a context file or folder) dropped on it (`lib/promptAttach.ts`)
* as an `@path` chip — so is a local file a browser on the dashboard's
* own machine drops or pastes with its `file://` path, never uploaded —
* own machine drops or pastes with its `file://` path, or pastes from a
* file manager's Copy its OS clipboard holds, never uploaded —
* a `bare` one takes a drop anywhere on its box;
* its `/command` and `@path` tokens are coloured by a mirror laid over
* the textarea (`lib/promptTokens.ts`);
@@ -78,6 +79,8 @@ import {
localAttachment,
localCandidates,
sameFilesHere,
fileManagerPaths,
isClipboardBitmap,
SKILL_MIME,
rememberUploads,
} from "../../lib/promptAttach";
@@ -463,21 +466,26 @@ export function PromptEditor({
};
/** Dropped or pasted files: by their own paths when the browser is on the
* dashboard's machine and the server finds the same files there (a file
* manager hands over `file://` paths beside the bytes), else uploaded. */
* manager hands over `file://` paths beside the bytes) or its file manager
* holds them (a paste: its clipboard; a drop: its selection — no upload,
* no size limit), else uploaded. */
const attachDropped = (
dt: DataTransfer | null | undefined,
list: FileList | null | undefined
list: FileList | File[] | null | undefined,
from: "paste" | "drop"
) => {
const files = Array.from(list ?? []);
if (files.length === 0) return false;
const paths = localCandidates(dt, files);
if (!paths) return attachImages(files);
if (!isLocalBrowser()) return attachImages(files);
const candidates = localCandidates(dt, files);
setUpload("uploading");
void sameFilesHere(paths, files).then((same) => {
if (!same) return void attachImages(files);
void (async () => {
let paths = candidates && (await sameFilesHere(candidates, files)) ? candidates : null;
paths ??= await fileManagerPaths(files, from);
if (!paths) return void attachImages(files);
setUpload(null);
onImagesChange((prev) => addAttachments(prev, paths.map(localAttachment)));
});
})();
return true;
};
// Anything attached since — a path, a panel row, a + menu pick — ends a past
@@ -518,7 +526,7 @@ export function PromptEditor({
} else if (skill) {
e.preventDefault();
onChange(`/${skill} ${value.replace(/^\s+/, "")}`);
} else if (attachDropped(e.dataTransfer, e.dataTransfer?.files)) e.preventDefault();
} else if (attachDropped(e.dataTransfer, e.dataTransfer?.files, "drop")) e.preventDefault();
else {
// Only text (a file manager's or an editor's `file://` list, a
// selection, a link): what the terminal beneath would paste.
@@ -683,17 +691,21 @@ export function PromptEditor({
onBlur={() => setTrigger(null)}
onKeyDown={handleKeyDown}
onPaste={(e) => {
if (attachDropped(e.clipboardData, e.clipboardData?.files)) e.preventDefault();
else if (pastedLocalFiles(e.clipboardData)) e.preventDefault();
else {
const pasted = Array.from(e.clipboardData?.files ?? []);
const text = e.clipboardData?.getData?.("text/plain") ?? "";
// Cells from a spreadsheet come with a picture of them: the text
// is what was copied (isClipboardBitmap).
const textWithPicture =
pasted.length > 0 && pasted.every(isClipboardBitmap) && !!text.trim();
if (!textWithPicture && attachDropped(e.clipboardData, pasted, "paste"))
e.preventDefault();
else if (!textWithPicture && pastedLocalFiles(e.clipboardData)) e.preventDefault();
else if (text.length > PASTE_AS_FILE_CHARS) {
// A very long paste goes in as a text file, like Claude Desktop:
// the prompt then carries its path, never the text (#68: a CLI's
// argument and a tmux message both have a size limit).
const text = e.clipboardData?.getData("text/plain") ?? "";
if (text.length > PASTE_AS_FILE_CHARS) {
e.preventDefault();
attachImages([new File([text], "pasted-text.txt", { type: "text/plain" })]);
}
e.preventDefault();
attachImages([new File([text], "pasted-text.txt", { type: "text/plain" })]);
}
}}
placeholder={placeholder}
+31 -11
View File
@@ -20,7 +20,11 @@
* reads them when the prompt is sent — an image as its bare path, which claude
* shows as `[Image #n]` like a native drop — except a PASTED image in a
* `claude` pane of a local-mode dashboard opened on its own machine, which is
* sent as ^V so `claude` reads the desktop clipboard itself, uploading nothing
* sent as its image-paste key (^V; Alt+V on Windows) so `claude` reads the
* desktop clipboard itself, uploading nothing — and a file manager's files
* pasted or dropped on that machine go in by the paths it holds them under
* (its clipboard / its selection: `POST /api/run/clipboard-files`,
* `selected-files`), with no upload and no size limit
* (`lib/nativePaste.ts`, a Settings switch); a browser on the dashboard's own
* machine pastes a file manager's files (dropped, or copied: `file://` text)
* by their own paths instead, uploading nothing. A text drag (a Files/Changes row, a skill) is pasted as its text. The pane also registers with
@@ -72,18 +76,20 @@ import { MENU_CURSOR_ROW, notePaneFocus, notePaneOutput, registerPane } from "..
import {
claudePastes,
copiedFilePaths,
isClipboardBitmap,
droppedFilePaths,
fileManagerPaths,
isLocalBrowser,
localCandidates,
sameFilesHere,
} from "../../lib/promptAttach";
import { attachImeInput } from "../../lib/imeInput";
import { readNativePaste } from "../../lib/nativePaste";
import { nativePasteKey, nativePasteWorks, readNativePaste } from "../../lib/nativePaste";
import { usePermissions } from "../AuthGate";
import { TERMINAL_FONTS, readTerminalFont } from "../../lib/terminalFont";
import { FONT_SIZE_EVENT, readTerminalFontSize } from "../../lib/fontSize";
type DropState = "hint" | "paste" | "uploading" | "failed" | "tooBig" | "tooBigLocal";
type DropState = "hint" | "paste" | "uploading" | "failed" | "tooBig" | "tooBigLocalPaste";
/** localStorage flag for xterm's screen-reader mode (a Settings toggle can set it later). */
export const SCREEN_READER_KEY = "agenthub.terminal.screenReader";
@@ -245,6 +251,11 @@ export const TerminalView = memo(function TerminalView({
// Ctrl+V was pressed and its paste has not arrived yet (see the key handler).
const ctrlVRef = useRef(false);
const { local: localMode } = usePermissions();
// Ask early whether `^V` can reach this machine's clipboard, so the first
// pasted screenshot already takes the right way.
useEffect(() => {
if (tool === "claude" && localMode && isLocalBrowser()) nativePasteWorks();
}, [tool, localMode]);
const onDrop = (e: DragEvent) => {
if (hasText(e)) {
@@ -262,7 +273,7 @@ export const TerminalView = memo(function TerminalView({
e.preventDefault();
dragDepth.current = 0;
const files = Array.from(e.dataTransfer.files);
void attachFiles(files, localCandidates(e.dataTransfer, files));
void attachFiles(files, localCandidates(e.dataTransfer, files), "drop");
};
// A pasted image (a screenshot) goes the way of a drop: xterm only reads
@@ -283,6 +294,9 @@ export const TerminalView = memo(function TerminalView({
return;
}
if (files.length === 0 && (!ctrlV || e.clipboardData?.getData("text/plain"))) return;
// Cells from a spreadsheet, a page's selection: text WITH a picture of
// it. A terminal pastes the text there, so does this.
if (files.every(isClipboardBitmap) && e.clipboardData?.getData("text/plain")?.trim()) return;
e.preventDefault();
e.stopPropagation();
// On the dashboard's own machine the browser's clipboard IS the pane's:
@@ -293,13 +307,14 @@ export const TerminalView = memo(function TerminalView({
localMode &&
isLocalBrowser() &&
readNativePaste() &&
nativePasteWorks() &&
files.length > 0 &&
files.every((f) => f.type.startsWith("image/"));
files.every(isClipboardBitmap);
if (nativeImage) {
termRef.current?.input("\x16", true);
termRef.current?.input(nativePasteKey(), true);
return;
}
if (files.length > 0) void attachFiles(files, localCandidates(e.clipboardData, files));
if (files.length > 0) void attachFiles(files, localCandidates(e.clipboardData, files), "paste");
else termRef.current?.input("\x16", true);
};
@@ -319,11 +334,15 @@ export const TerminalView = memo(function TerminalView({
/** Pastes the files' paths: their own (`local`, the `file://` paths a
* browser on the dashboard's machine is handed beside the bytes) once the
* server finds the same files there — nothing uploaded —, else the paths
* the uploads were saved at. A refused file does not cost the others. */
const attachFiles = async (files: File[], local: string[] | null = null) => {
* that machine's file manager holds them under (a paste: its clipboard; a
* drop: its selection, what the drag carries — no upload, no size limit,
* as a native terminal), else the paths the uploads were saved at. A
* refused file does not cost the others. */
const attachFiles = async (files: File[], local: string[] | null, from: "paste" | "drop") => {
if (files.length === 0) return;
setDrop("uploading");
let paths = local && (await sameFilesHere(local, files)) ? local : null;
paths ??= await fileManagerPaths(files, from);
const errors: unknown[] = [];
if (!paths) {
const results = await Promise.allSettled(files.map((f) => api.run.dropFile(f, runId)));
@@ -341,7 +360,8 @@ export const TerminalView = memo(function TerminalView({
if (tooBig) setLimitMb(Math.round((tooBig.maxBytes ?? 0) / 1024 / 1024));
if (!errors.length) setDrop(null);
else if (!tooBig) flash("failed");
else flash(isLocalBrowser() ? "tooBigLocal" : "tooBig", 6000); // a sentence to read
// A terminal has no + menu: on this machine, a paste goes by path.
else flash(isLocalBrowser() ? "tooBigLocalPaste" : "tooBig", 6000); // a sentence to read
};
useEffect(() => {
@@ -959,7 +979,7 @@ export const TerminalView = memo(function TerminalView({
<div
data-testid="terminal-drop"
className={`pointer-events-none absolute inset-2 flex items-center justify-center rounded-lg border-2 border-dashed bg-black/50 text-sm font-medium ${
drop === "failed" || drop === "tooBig" || drop === "tooBigLocal"
drop === "failed" || drop === "tooBig" || drop === "tooBigLocalPaste"
? "border-red-400 text-red-300"
: "border-sky-400 text-sky-200"
}`}
@@ -60,6 +60,8 @@ vi.mock("../../../lib/api", async (importOriginal) => {
files: r({ items: [] }),
dropFile: r({ path: "/tmp/agenthub-drops-1/a.png" }),
localPaths: r({ sizes: [1] }),
clipboardFiles: r({ files: [] }),
selectedFiles: r({ files: [] }),
},
ccConfig: {
commands: r({
@@ -377,6 +379,35 @@ describe("PromptEditor — newlines and images", () => {
expect(screen.getByTestId("sent").textContent).toBe("read @/home/u/demo.mp4");
});
// Chrome on Windows pastes a file manager's copy as bytes only: the path
// comes off the OS clipboard, so a file over the upload limit attaches too.
it("attaches a pasted file manager copy by the path the OS clipboard holds", async () => {
vi.mocked(api.run.clipboardFiles).mockResolvedValueOnce({
files: [{ path: "C:\\Users\\u\\big.iso", size: 3_000_000_000, dir: false }],
});
vi.mocked(api.run.dropFile).mockClear();
const onImagesChange = vi.fn();
render(
<PromptEditor
value=""
onChange={() => {}}
onSubmit={() => {}}
placeholder="Ask anything…"
slash={false}
fileCwd="/home/u"
images={[]}
onImagesChange={onImagesChange}
/>
);
const iso = new File(["x"], "big.iso");
Object.defineProperty(iso, "size", { value: 3_000_000_000 });
fireEvent.paste(box(), { clipboardData: { files: [iso], getData: () => "" } });
await vi.waitFor(() => expect(onImagesChange).toHaveBeenCalled());
const update = onImagesChange.mock.calls[0]![0] as (p: unknown[]) => { path: string }[];
expect(update([]).map((a) => a.path)).toEqual(["@C:/Users/u/big.iso"]);
expect(api.run.dropFile).not.toHaveBeenCalled();
});
it("uploads a dropped file whose path the server does not have (an SSH tunnel's laptop)", async () => {
vi.mocked(api.run.localPaths).mockResolvedValueOnce({ sizes: [null] });
const onImagesChange = vi.fn();
@@ -10,9 +10,18 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from "vitest";
import { render, cleanup, act, fireEvent, screen, waitFor } from "@testing-library/react";
import {
render,
cleanup,
act,
createEvent,
fireEvent,
screen,
waitFor,
} from "@testing-library/react";
import { TerminalView, loadXterm } from "../TerminalView";
import { pasteToLane, pasteToRun } from "../../../lib/panePaste";
import { __resetNativePaste, nativePasteWorks } from "../../../lib/nativePaste";
const writeMock = vi.fn();
const onDataHandlers: Array<(d: string) => void> = [];
@@ -27,11 +36,18 @@ const inputMock = vi.fn();
let textarea: HTMLTextAreaElement;
const dropFileMock = vi.fn();
const localPathsMock = vi.fn();
const nativePasteMock = vi.fn(() => Promise.resolve({ native: true }));
type Held = { files: { path: string; size: number | null; dir: boolean }[] };
const clipboardFilesMock = vi.fn((): Promise<Held> => Promise.resolve({ files: [] }));
const selectedFilesMock = vi.fn((): Promise<Held> => Promise.resolve({ files: [] }));
vi.mock("../../../lib/api", () => ({
api: {
run: {
dropFile: (f: File, lane?: string) => dropFileMock(f, lane),
localPaths: (paths: string[]) => localPathsMock(paths),
nativePaste: () => nativePasteMock(),
clipboardFiles: () => clipboardFilesMock(),
selectedFiles: () => selectedFilesMock(),
},
},
}));
@@ -590,11 +606,13 @@ describe("TerminalView", () => {
expect(dropFileMock).toHaveBeenCalledWith(img, "r1");
});
it("hands a local claude pane's pasted image to claude as ^V, uploading nothing", () => {
it("hands a local claude pane's pasted image to claude as ^V, uploading nothing", async () => {
__resetNativePaste();
dropFileMock.mockReset();
inputMock.mockClear();
pasteMock.mockClear();
render(<TerminalView runId="r1" wsBaseUrl="ws://x" tool="claude" />);
await waitFor(() => expect(nativePasteWorks()).toBe(true));
const img = new File([new Uint8Array([137, 80])], "image.png", { type: "image/png" });
fireEvent.paste(screen.getByTestId("terminal-view"), {
clipboardData: { files: [img], getData: () => "" },
@@ -604,6 +622,41 @@ describe("TerminalView", () => {
expect(pasteMock).not.toHaveBeenCalled();
});
// Claude Code on Windows binds its image paste to Alt+V; a ^V did nothing.
it("sends Alt+V instead of ^V when the dashboard runs on Windows", async () => {
__resetNativePaste();
nativePasteMock.mockResolvedValueOnce({ native: true, key: "alt+v" } as never);
dropFileMock.mockReset();
inputMock.mockClear();
render(<TerminalView runId="r1" wsBaseUrl="ws://x" tool="claude" />);
await waitFor(() => expect(nativePasteWorks()).toBe(true));
const img = new File([new Uint8Array([137, 80])], "image.png", { type: "image/png" });
fireEvent.paste(screen.getByTestId("terminal-view"), {
clipboardData: { files: [img], getData: () => "" },
});
expect(inputMock).toHaveBeenCalledWith("\x1bv", true);
expect(inputMock).not.toHaveBeenCalledWith("\x16", true);
expect(dropFileMock).not.toHaveBeenCalled();
});
// No wl-paste/xclip (or no display) on the dashboard's machine: ^V would
// paste nothing, so the screenshot is uploaded like a remote browser's.
it("uploads a claude pane's pasted image when the machine cannot read its clipboard", async () => {
__resetNativePaste();
nativePasteMock.mockResolvedValueOnce({ native: false });
dropFileMock.mockReset().mockResolvedValue({ path: "/tmp/agenthub-drops-1/ab-image.png" });
inputMock.mockClear();
render(<TerminalView runId="r1" wsBaseUrl="ws://x" tool="claude" />);
await waitFor(() => expect(nativePasteMock).toHaveBeenCalled());
await Promise.resolve();
const img = new File([new Uint8Array([137, 80])], "image.png", { type: "image/png" });
fireEvent.paste(screen.getByTestId("terminal-view"), {
clipboardData: { files: [img], getData: () => "" },
});
await waitFor(() => expect(dropFileMock).toHaveBeenCalledWith(img, "r1"));
expect(inputMock).not.toHaveBeenCalledWith("\x16", true);
});
it("uploads a claude pane's pasted image once native paste is turned off", async () => {
localStorage.setItem("agenthub.terminal.nativePaste", "off");
dropFileMock.mockReset().mockResolvedValue({ path: "/tmp/agenthub-drops-1/ab-image.png" });
@@ -621,6 +674,100 @@ describe("TerminalView", () => {
}
});
// A file manager's Copy reaches Chrome on Windows as bytes and a name only:
// the pane gets the file's own path off the OS clipboard — no upload, so
// no size limit (a 3 GB video too), as a native terminal pastes it.
it("pastes a copied file by the path the OS clipboard holds, uploading nothing", async () => {
__resetNativePaste();
dropFileMock.mockReset();
pasteMock.mockClear();
inputMock.mockClear();
clipboardFilesMock.mockResolvedValueOnce({
files: [{ path: "C:\\Users\\u\\Videos\\demo.mp4", size: 3_000_000_000, dir: false }],
});
render(<TerminalView runId="r1" wsBaseUrl="ws://x" tool="claude" />);
await waitFor(() => expect(nativePasteWorks()).toBe(true));
const video = new File([new Uint8Array([1])], "demo.mp4", { type: "video/mp4" });
Object.defineProperty(video, "size", { value: 3_000_000_000 });
fireEvent.paste(screen.getByTestId("terminal-view"), {
clipboardData: { files: [video], getData: () => "" },
});
await waitFor(() => expect(pasteMock).toHaveBeenCalledWith("@C:/Users/u/Videos/demo.mp4 "));
expect(dropFileMock).not.toHaveBeenCalled();
expect(inputMock).not.toHaveBeenCalledWith("\x16", true);
});
// Chrome hands a file dragged from Explorer no path; the drag carries the
// items selected there, so the pane gets their own paths — as a native
// terminal types them — and nothing is uploaded, whatever the size.
it("drops a file manager's file by the path selected there, uploading nothing", async () => {
dropFileMock.mockReset();
pasteMock.mockClear();
selectedFilesMock.mockResolvedValueOnce({
files: [{ path: "C:\\Users\\u\\Videos\\big.mkv", size: 5_000_000_000, dir: false }],
});
render(<TerminalView runId="r1" wsBaseUrl="ws://x" tool="claude" />);
const video = new File([new Uint8Array([1])], "big.mkv");
Object.defineProperty(video, "size", { value: 5_000_000_000 });
fireEvent.drop(screen.getByTestId("terminal-view"), {
dataTransfer: { files: [video], types: ["Files"], getData: () => "" },
});
await waitFor(() => expect(pasteMock).toHaveBeenCalledWith("@C:/Users/u/Videos/big.mkv "));
expect(dropFileMock).not.toHaveBeenCalled();
});
it("uploads a dropped file the file manager does not hold selected", async () => {
dropFileMock.mockReset().mockResolvedValue({ path: "/tmp/agenthub-drops-1/ab-notes.txt" });
selectedFilesMock.mockResolvedValueOnce({
files: [{ path: "/home/u/other.txt", size: 1, dir: false }],
});
render(<TerminalView runId="r1" wsBaseUrl="ws://x" tool="codex" />);
const notes = new File(["x"], "notes.txt");
fireEvent.drop(screen.getByTestId("terminal-view"), {
dataTransfer: { files: [notes], types: ["Files"], getData: () => "" },
});
await waitFor(() => expect(dropFileMock).toHaveBeenCalledWith(notes, "r1"));
});
// A copied image FILE is not a screenshot: claude's own paste finds no
// picture on a clipboard holding a file, so it goes by path too.
it("pastes a copied image file by path instead of claude's image paste", async () => {
__resetNativePaste();
dropFileMock.mockReset();
pasteMock.mockClear();
inputMock.mockClear();
clipboardFilesMock.mockResolvedValueOnce({
files: [{ path: "/home/u/Pictures/logo.png", size: 2, dir: false }],
});
render(<TerminalView runId="r1" wsBaseUrl="ws://x" tool="claude" />);
await waitFor(() => expect(nativePasteWorks()).toBe(true));
const logo = new File([new Uint8Array([137, 80])], "logo.png", { type: "image/png" });
fireEvent.paste(screen.getByTestId("terminal-view"), {
clipboardData: { files: [logo], getData: () => "" },
});
await waitFor(() => expect(pasteMock).toHaveBeenCalledWith("/home/u/Pictures/logo.png "));
expect(inputMock).not.toHaveBeenCalledWith("\x16", true);
expect(dropFileMock).not.toHaveBeenCalled();
});
// Cells from a spreadsheet come with a picture of them; a terminal pastes
// the text, so the browser's paste is left to xterm.
it("leaves text that comes with a picture of it to xterm's paste", async () => {
__resetNativePaste();
dropFileMock.mockReset();
inputMock.mockClear();
render(<TerminalView runId="r1" wsBaseUrl="ws://x" tool="claude" />);
await waitFor(() => expect(nativePasteWorks()).toBe(true));
const pic = new File([new Uint8Array([137, 80])], "image.png", { type: "image/png" });
const ev = createEvent.paste(screen.getByTestId("terminal-view"), {
clipboardData: { files: [pic], getData: (t: string) => (t === "text/plain" ? "a\tb" : "") },
});
fireEvent(screen.getByTestId("terminal-view"), ev);
expect(ev.defaultPrevented).toBe(false);
expect(inputMock).not.toHaveBeenCalled();
expect(dropFileMock).not.toHaveBeenCalled();
});
// Ctrl+V is how claude pastes an image; xterm would send ^V, and claude then
// reads the SERVER's clipboard, not the browser's. Text on the clipboard is
// pasted like any browser paste: sending ^V for it pasted nothing.
+2 -1
View File
@@ -88,5 +88,6 @@
"now": "now",
"users": "Users",
"admin": "Administration",
"files": "Files"
"files": "Files",
"snapshot": "Snapshot"
}
+1
View File
@@ -73,6 +73,7 @@
"failed": "Could not attach the file",
"tooBig": "File too large to upload (max {{mb}} MB) — put it in the workspace folder and attach it from Files",
"tooBigLocal": "File too large to upload (max {{mb}} MB) — attach it by path instead: + → Choose a file on this computer",
"tooBigLocalPaste": "File too large to upload (max {{mb}} MB) — copy it in your file manager and paste it here instead (Ctrl+V): a pasted file goes in by its own path",
"remove": "Remove attachment",
"view": "View image"
},
+1 -1
View File
@@ -1142,7 +1142,7 @@
},
"nativePaste": {
"label": "Paste images natively",
"description": "On this machine, an image pasted into a Claude pane is read by Claude from your clipboard, like in a normal terminal — nothing is uploaded. Turn it off if you open the dashboard through an SSH tunnel: Claude would read the server's clipboard. This browser only."
"description": "On this machine, an image pasted into a Claude pane is read by Claude from your clipboard, like in a normal terminal — nothing is uploaded. Needs wl-paste or xclip on Linux; without them the image is uploaded instead. Turn it off if you open the dashboard through an SSH tunnel: Claude would read the server's clipboard. This browser only."
},
"terminalFont": {
"title": "Terminal font",
+2 -1
View File
@@ -88,5 +88,6 @@
"now": "bây giờ",
"users": "Người dùng",
"admin": "Quản trị",
"files": "Quản lý file"
"files": "Quản lý file",
"snapshot": "Snapshot"
}
+1
View File
@@ -73,6 +73,7 @@
"failed": "Không đính kèm được file",
"tooBig": "File quá lớn để tải lên (tối đa {{mb}} MB) — hãy đặt file vào thư mục workspace rồi đính kèm từ Files",
"tooBigLocal": "File quá lớn để tải lên (tối đa {{mb}} MB) — hãy đính kèm bằng đường dẫn: + → Chọn file trên máy này",
"tooBigLocalPaste": "File quá lớn để tải lên (tối đa {{mb}} MB) — hãy copy file trong trình quản lý file rồi dán vào đây (Ctrl+V): file được dán sẽ dùng đường dẫn gốc",
"remove": "Bỏ đính kèm",
"view": "Xem ảnh"
},
+1 -1
View File
@@ -1130,7 +1130,7 @@
},
"nativePaste": {
"label": "Dán ảnh kiểu native",
"description": "Trên máy này, ảnh dán vào pane Claude được Claude tự đọc từ clipboard, như trong terminal thường — không upload gì. Tắt nếu bạn mở dashboard qua SSH tunnel: khi đó Claude sẽ đọc clipboard của server. Chỉ áp dụng cho trình duyệt này."
"description": "Trên máy này, ảnh dán vào pane Claude được Claude tự đọc từ clipboard, như trong terminal thường — không upload gì. Trên Linux cần wl-paste hoặc xclip; thiếu thì ảnh được upload thay thế. Tắt nếu bạn mở dashboard qua SSH tunnel: khi đó Claude sẽ đọc clipboard của server. Chỉ áp dụng cho trình duyệt này."
},
"terminalFont": {
"title": "Font terminal",
+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(/[\\/]$/, "")
@@ -7244,7 +7244,7 @@ exports[`screen snapshots > Settings — about 1`] = `
<span
class="text-status-warning/90"
>
UI build v2.8.24 (rebuild client to match)
UI build v2.8.25 (rebuild client to match)
</span>
</p>
</div>
+7 -4
View File
@@ -2560,10 +2560,10 @@ curl -X DELETE http://localhost:4820/api/pricing/gpt-5.1-codex
#### Total Cost
```http
GET /api/pricing/cost?tz_offset=<minutes>&sources=<csv>&lane=<id>&tool=<id>&from=<YYYY-MM-DD>&to=<YYYY-MM-DD>&date=<YYYY-MM-DD>
GET /api/pricing/cost?tz_offset=<minutes>&sources=<csv>&lane=<id>&tool=<id>&from=<YYYY-MM-DD>&to=<YYYY-MM-DD>&date=<YYYY-MM-DD>&mine=1
```
The total cost with a per-model `breakdown`, plus `daily_costs: [{date, cost}]` by local date (`tz_offset` is the browser's `getTimezoneOffset()`). `date=<YYYY-MM-DD>` prices only that local day — `total_cost`, `breakdown`, `by_tool` and `daily_costs` all cover just it (any other shape is `400 INVALID_INPUT`); the status bar's **Today** asks for the local date and shows the day's token total beside it, listing tokens and cost per model on hover (as it does for **Workspace** and **Session**). A day is the day the tokens were SPENT: each session's total is split over the local days of its `token_usage_hourly` rows (one per UTC hour, from each Claude transcript message's timestamp), and whatever the total holds beyond those rows — a session recorded before that table and whose transcript is gone, sub-agent files, Codex and Antigravity sessions — is booked to the session's start date. The days always add up to the total. `lane=<id>` narrows it to the sessions that workspace owns (`sessions.lane_id`, or held by one of its `dashboard_runs` and not stamped yet — the rule of `GET /api/sessions?lane=`; a non-numeric id is `400 INVALID_INPUT`); the status bar shows it as **Workspace $…** on the Workspace page, beside **Session $…** from `GET /api/pricing/cost/:sessionId` for the replayed session or the focused pane's. `tool=<id>` narrows it to one AI tool's sessions (`sessions.tool`), and `by_tool: [{tool, cost}]` (most expensive first) splits the total per tool — each tool priced on its own rows, so the code-execution free allowance applies per tool. A Codex turn on the fast/priority service tier is booked as `speed = fast` (priced with the model's priority rates, which the pricing sync writes into the fast columns), one on the flex tier as `service_tier = flex` (half price, like batch). A Claude request whose whole prompt (input + cache read + cache write) is above 100k tokens is also counted into the bucket's `long_*_tokens` (returned on each `breakdown` entry) and that part is priced at the rule's `long_input_per_mtok` / `long_output_per_mtok` (Haiku 5.5: 5x; cache rates scale by the input ratio; a rule with no long rates prices it at its standard rates).
The total cost with a per-model `breakdown`, plus `daily_costs: [{date, cost}]` by local date (`tz_offset` is the browser's `getTimezoneOffset()`). `date=<YYYY-MM-DD>` prices only that local day — `total_cost`, `breakdown`, `by_tool` and `daily_costs` all cover just it (any other shape is `400 INVALID_INPUT`); the status bar's **Today** asks for the local date with `mine=1` and shows the day's token total beside it, listing tokens and cost per model on hover (as it does for **Workspace** and **Session**). A day is the day the tokens were SPENT: each session's total is split over the local days of its `token_usage_hourly` rows (one per UTC hour, from each Claude transcript message's timestamp), and whatever the total holds beyond those rows — a session recorded before that table and whose transcript is gone, sub-agent files, Codex and Antigravity sessions — is booked to the session's start date. The days always add up to the total. `lane=<id>` narrows it to the sessions that workspace owns (`sessions.lane_id`, or held by one of its `dashboard_runs` and not stamped yet — the rule of `GET /api/sessions?lane=`; a non-numeric id is `400 INVALID_INPUT`); the status bar shows it as **Workspace $…** on the Workspace page, beside **Session $…** from `GET /api/pricing/cost/:sessionId` for the replayed session or the focused pane's. `mine=1` narrows it to the caller's OWN sessions (those of workspaces they own) even for an admin or analyst, who otherwise see every user's sum — so each user's **Today** (and the Claude Code statusline's, which asks as the pane's workspace owner) is their own; it is a no-op in local mode. `tool=<id>` narrows it to one AI tool's sessions (`sessions.tool`), and `by_tool: [{tool, cost}]` (most expensive first) splits the total per tool — each tool priced on its own rows, so the code-execution free allowance applies per tool. A Codex turn on the fast/priority service tier is booked as `speed = fast` (priced with the model's priority rates, which the pricing sync writes into the fast columns), one on the flex tier as `service_tier = flex` (half price, like batch). A Claude request whose whole prompt (input + cache read + cache write) is above 100k tokens is also counted into the bucket's `long_*_tokens` (returned on each `breakdown` entry) and that part is priced at the rule's `long_input_per_mtok` / `long_output_per_mtok` (Haiku 5.5: 5x; cache rates scale by the input ratio; a rule with no long rates prices it at its standard rates).
`from` / `to` (local dates, inclusive, read with `tz_offset`; either end may be left open) narrow `total_cost`, `breakdown`, `by_tool`, `feature_costs` and `unpriced_models` to the tokens SPENT on those days (the same split as `daily_costs`). `daily_costs` itself stays the whole series, so a 30-day trend or a month-to-date pace is unaffected by the range. A malformed date, a date from year 9999, or `from` after `to` is `400 INVALID_INPUT`.
@@ -2957,7 +2957,10 @@ GET /api/run/interrupted {items} — sessions whose pane died under
POST /api/run/interrupted/dismiss {sessionIds: string[]} (≤ 100) → {dismissed}: stop offering them; resumes nothing. 400 EBADFIELD otherwise
GET /api/run/files?cwd=&q=&dirs=1 Fuzzy file search inside cwd for the @-file autocomplete (dirs=1 adds folders as `dir/`, for the + menu)
POST /api/run/drop-image Raw image body (png/jpeg/gif/webp, ≤20 MB) → {path}: saved 0600 under <data dir>/drops/ (0700; S3: the member's ~/.agenthub/handoff/drops/) with a random name, pruned after a day; the terminal pastes the path (a `claude` pane: an image as its bare path, each in a paste of its own, which Claude Code shows as `[Image #n]`; any other file as an `@` mention)
POST /api/run/drop-file?name=&laneId= Any raw file body (≤20 MB; ≤2 GB from a loopback browser outside server mode, streamed to disk — Chrome hands a dropped file no `file://` path) → {path}: same dir (S3: the handoff dir of the account workspace `laneId`'s panes run as, else the requester's), 0600, named <random>-<name reduced to a [A-Za-z0-9._-] basename>; what the terminal (drop or paste, Ctrl+V included), the prompt box and its paste use for every dropped file or image — except a browser on a loopback host dropping or pasting a file manager's files, whose `file://` paths are attached as they are, uploading nothing, once `POST /api/run/local-paths` finds the same files here, and an image PASTED into a `claude` pane by a loopback browser of a local-mode dashboard, which is sent as ^V so `claude` reads the desktop clipboard itself (Settings → Appearance → **Paste images natively**, on by default, per browser — off for an SSH tunnel). Both drop routes answer 413 `ETOOBIG` (with `max_bytes`, the limit the UI shows) to a `Content-Length` over their limit before reading the body, and close the connection; of several files only the refused ones are lost
POST /api/run/clipboard-files → {files:[{path,size,dir}]}: the files a file manager's Copy put on THIS machine's clipboard (Windows GetFileDropList, macOS NSPasteboard, Linux text/uri-list via wl-paste/xclip), each that exists here (`size` null for a folder) — local mode + loopback only, else 403 `EREMOTE`; a pasted file whose name and size match is attached by that path, no upload and no size limit
POST /api/run/selected-files → {files:[{path,size,dir}]}: the same for a DROP — the items selected in THIS machine's file manager, which is what a drag from it carries (Windows Explorer windows and the desktop via Shell.Application, macOS Finder's selection — macOS asks once to let AgentHub control Finder; Linux file managers publish none, so a Linux drop uses the browser's own `file://` list or uploads). Nothing is searched for on disk
GET /api/run/native-paste → {native, key}: whether a `claude` pane can read this machine's clipboard itself (see drop-file below), and `key` — `ctrl+v`, or `alt+v` on Windows — the chord Claude Code binds its image paste to there, which the terminal sends
POST /api/run/drop-file?name=&laneId= Any raw file body (≤20 MB; ≤2 GB from a loopback browser outside server mode, streamed to disk — Chrome hands a dropped file no `file://` path) → {path}: same dir (S3: the handoff dir of the account workspace `laneId`'s panes run as, else the requester's), 0600, named <random>-<name reduced to a [A-Za-z0-9._-] basename>; what the terminal (drop or paste, Ctrl+V included), the prompt box and its paste use for every dropped file or image — except a browser on a loopback host dropping or pasting a file manager's files, whose `file://` paths are attached as they are, uploading nothing, once `POST /api/run/local-paths` finds the same files here, and an image PASTED into a `claude` pane by a loopback browser of a local-mode dashboard, which is sent as ^V (Alt+V on Windows, Claude Code's binding there) so `claude` reads the desktop clipboard itself (Settings → Appearance → **Paste images natively**, on by default, per browser — off for an SSH tunnel) and only while `GET /api/run/native-paste` answers `{native:true}`: local mode, a loopback request, and on Linux a reachable display plus `wl-paste` (Wayland) or `xclip` (X11) — without one the `^V` pasted nothing, so the image is uploaded instead. Both drop routes answer 413 `ETOOBIG` (with `max_bytes`, the limit the UI shows) to a `Content-Length` over their limit before reading the body, and close the connection; of several files only the refused ones are lost
POST /api/run/local-paths {paths:[<absolute>…]} (1–100) → {sizes:[bytes|null…]}: each path's size when it is a regular file on this machine, else null. The composer and the terminal use a dropped file's own `file://` path only when every size matches the dropped bytes' — "localhost" in the address bar is no proof (an SSH tunnel's browser is on another machine). 403 EREMOTE in server mode (a pane runs as an account that cannot read the host's files) and to anything but a loopback socket without X-Forwarded-For/Forwarded; 400 EBADFIELD otherwise
GET /api/run/drop/:name A dropped png/jpg/gif/webp, or txt/md/csv/tsv/log/json/xml/yaml as text/plain, served back (nosniff, sandbox CSP) from the requester's own drop dir (S3: their member account's, owner-checked — for an admin, then the host's, where their host-folder workspaces drop); only a name drop-image/drop-file made → 404 otherwise
GET /api/run/:id/permission-mode A live claude pane's mode, read off its mode line → {mode} (null while a menu hides it)
@@ -3008,7 +3011,7 @@ GET /api/ai-tools/:id/icon The tool's favicon, fetched once by the se
Each tool: `{ id, label, command, homepageUrl, installed, path, enabled, source: "builtin"|"custom", launcher, prompt: { mode }, resume: bool, bypass: bool, proxyCapable: bool }` — flags only, never the arg arrays. Each tool also carries `modelFlag` (the CLI takes a model: Claude, Codex, or a catalog `model: { flag, vendor? }` field) and `defaultModel` (what a new run starts with, `null` = the CLI's own; a `PUT` with `""`/`null` clears it, an id that is not a plain model name is `400 EBADMODEL`). The config files are only those a built-in or release catalog file lists in `config` (`~/`-relative, no `..`, no credential-looking name); a read or save answers `404 ENOFILE` (no such index) / `ENOENT` (missing — never created), `403 ESECRET` (the name, or a symlink target's, looks like a credential), `413 ETOOBIG` (> 1 MB), `415 EBINARY`, `400 EBADSYNTAX` (JSON/JSONC that does not parse), `409 ESTALE` (the file is no longer at `baseSha`); a save backs the old text up under `<data dir>/ai-tool-config-backups/<id>/` and is same-origin only. `GET /:id/models` reads each tool's own source: Codex's `$CODEX_HOME/models_cache.json` (listed entries by priority — nothing else from that file), Claude's aliases named after the newest priced version plus the priced pinned versions, a catalog tool's vendor-priced ids plus the models its own sessions used; `flag: false` and `[]` for a tool with no model flag, `404 ENOTOOL` for an unknown id. `default` is the effective default: the stored one if installed and enabled, else `claude` if installed and enabled, else the first installed enabled tool, else `null`. `PUT` answers `404 ENOTOOL` for an unknown id, `400 EBADDEFAULT` for a default that is not installed or disabled, `400 EBADFIELD` for a non-boolean `enabled`; disabling the default clears it. Writes are same-origin only. State lives in `<data dir>/ai-tools.json`. Each tool also carries `install` (`{kind:"npm", package}` or `{kind:"script", url}`, else null) and `update` (`{kind:"npm"}`, `{kind:"script"}`, `{kind:"self", args}`, else null), and the body carries `catalog: { source: "builtin"|"remote", tag, fetchedAt }` — `remote` while a copy of the newest release's catalog, newer than this build, is in use (synced daily, off with `AGENTHUB_AI_TOOLS_SYNC=0`).
**Install / update jobs.** Commands come only from built-in or release catalog files, never from the request or `DASHBOARD_AI_TOOLS_DIR`. npm is the login shell's (`npm install -g <package>@latest`, plus `--prefix ~/.local` when npm's global prefix is not writable by the server's account — a system-wide Node; a tool under `~/.local/lib/node_modules` is updated the same way); a script is downloaded by the server (https, same site as the tool's homepage — or, after a redirect, exactly the catalog entry's `install.redirect` URL, e.g. omp's `omp.sh/install` → its GitHub raw script —, ≤ 1 MB) and run as `bash <file>`; `self` runs `<detected path> ...args`. One job server-wide; the job is `{ id, tool, action, status: "running"|"ok"|"failed", startedAt, endedAt, exitCode, log }` (last 256 KB, 10-minute timeout) and lives in memory. A successful install enables the tool. Errors: `404 ENOTOOL`; `400 ENOINSTALL`/`ENOUPDATE`/`ENOTHING`; `409 EINSTALLED`/`ENOTINSTALLED`/`ENOTNPM` (npm update of a tool whose binary is under neither `npm prefix -g` nor `~/.local/lib/node_modules`)/`ENONPM`/`EBUSY`. Icons are the root `/favicon.ico`, raster only by magic bytes (never SVG), ≤ 256 KB, cached in `<data dir>/runtime/ai-tool-icons/`, served with `nosniff` and `Content-Security-Policy: sandbox`; an `<img>` passes the dashboard token as `?token=`. The client shows its own shipped mark instead where it has one (`client/public/ai-tools/<id>.svg` — claude, codex, openclaw, and hermes as a `-black`/`-white` pair swapped by the theme; `BUNDLED` in `client/src/components/AiToolIcon.tsx`), so this route is asked only for the other tools.
**Install / update jobs.** Commands come only from built-in or release catalog files, never from the request or `DASHBOARD_AI_TOOLS_DIR`. npm is the login shell's (`npm install -g <package>@latest`, plus `--prefix ~/.local` when npm's global prefix is not writable by the server's account — a system-wide Node; a tool under `~/.local/lib/node_modules` is updated the same way); a script is downloaded by the server (https, same site as the tool's homepage — or, after a redirect, exactly the catalog entry's `install.redirect` URL, e.g. omp's `omp.sh/install` → its GitHub raw script —, ≤ 1 MB) and run as `bash <file>`; `self` runs `<detected path> ...args` — except for an npm-installed tool under `~/.local/lib/node_modules`, which gets the `--prefix ~/.local` npm install instead (`codex update` reinstalls into npm's global prefix and fails `EACCES` there). One job server-wide; the job is `{ id, tool, action, status: "running"|"ok"|"failed", startedAt, endedAt, exitCode, log }` (last 256 KB, 10-minute timeout) and lives in memory. A successful install enables the tool. Errors: `404 ENOTOOL`; `400 ENOINSTALL`/`ENOUPDATE`/`ENOTHING`; `409 EINSTALLED`/`ENOTINSTALLED`/`ENOTNPM` (npm update of a tool whose binary is under neither `npm prefix -g` nor `~/.local/lib/node_modules`)/`ENONPM`/`EBUSY`. Icons are the root `/favicon.ico`, raster only by magic bytes (never SVG), ≤ 256 KB, cached in `<data dir>/runtime/ai-tool-icons/`, served with `nosniff` and `Content-Security-Policy: sandbox`; an `<img>` passes the dashboard token as `?token=`. The client shows its own shipped mark instead where it has one (`client/public/ai-tools/<id>.svg` — claude, codex, openclaw, and hermes as a `-black`/`-white` pair swapped by the theme; `BUNDLED` in `client/src/components/AiToolIcon.tsx`), so this route is asked only for the other tools.
### AI tool login profiles
+1 -1
View File
@@ -13,7 +13,7 @@ Opus 5.5 · main · ███░░░░░░░ 34% · $1.23 · today $45.68
| branch | `git symbolic-ref --short HEAD` in the session's cwd (short sha when detached; 500 ms cap) |
| context bar | `context_window.used_percentage`, else `current_usage` (input + cache) ÷ `context_window_size`; green < 50 %, yellow < 80 %, red above |
| `$1.23` | this session, Claude Code's own `cost.total_cost_usd` |
| `today $…` | every session today, from the dashboard: `GET /api/pricing/cost?tz_offset=<local>` → today's `daily_costs` row |
| `today $…` | your sessions today, from the dashboard: `GET /api/pricing/cost?tz_offset=<local>&mine=1` → today's `daily_costs` row (in server mode only the pane's workspace owner's sessions, even for an admin; locally every session) |
Today's cost has a **1-second timeout** and is cached for 30 s in
`<os tmpdir>/agenthub-statusline-<uid>.json` — a failed fetch is cached too, so a
+88
View File
@@ -20326,6 +20326,94 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/api/run/clipboard-files:
post:
tags:
- Run
summary: The files a file manager's Copy put on this machine's clipboard
operationId: runClipboardFiles
description: 'For a browser on this machine: the files the OS clipboard holds (Windows GetFileDropList, macOS NSPasteboard file URLs, Linux text/uri-list via wl-paste/xclip), each that exists here with its size (`null` for a folder), at most 100 — so a pasted copy is attached by its own path, with no upload and no size limit. The client uses a path only when name and size match the pasted file. 403 EREMOTE in server mode or for any non-loopback (or proxied) request.'
responses:
'200':
description: The clipboard's files; empty when it holds none or no reader is installed.
content:
application/json:
schema:
type: object
properties:
files:
type: array
items:
type: object
properties:
path:
type: string
size:
type: integer
nullable: true
dir:
type: boolean
'403':
description: EREMOTE.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/api/run/selected-files:
post:
tags:
- Run
summary: The files selected in this machine's file manager (what a drag carries)
operationId: runSelectedFiles
description: 'For a browser on this machine: the items selected in Explorer (every window, and the desktop) or Finder — a drag from the file manager carries exactly those — each that exists here with its size (`null` for a folder), at most 100, so a dropped file is attached by its own path with no upload and no size limit. Linux file managers publish no selection: always empty there. Nothing is searched for on disk. 403 EREMOTE in server mode or for any non-loopback (or proxied) request.'
responses:
'200':
description: The selected files; empty when none or no reader is available.
content:
application/json:
schema:
type: object
properties:
files:
type: array
items:
type: object
properties:
path:
type: string
size:
type: integer
nullable: true
dir:
type: boolean
'403':
description: EREMOTE.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/api/run/native-paste:
get:
tags:
- Run
summary: Whether a claude pane can read this machine's clipboard itself
operationId: runNativePaste
description: '`native`: local mode, a loopback request, and on Linux a reachable display plus `wl-paste` or `xclip` — then the terminal hands a pasted screenshot to `claude` as its image-paste key instead of uploading it. `key`: that chord on this machine — `alt+v` on Windows, `ctrl+v` elsewhere (Claude Code''s own bindings).'
responses:
'200':
description: The answer.
content:
application/json:
schema:
type: object
properties:
native:
type: boolean
key:
type: string
enum:
- ctrl+v
- alt+v
/api/run/interrupted:
get:
tags:
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "agenthub",
"version": "2.8.24",
"version": "2.8.25",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "agenthub",
"version": "2.8.24",
"version": "2.8.25",
"hasInstallScript": true,
"license": "UNLICENSED",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "agenthub",
"version": "2.8.24",
"version": "2.8.25",
"bin": {
"agenthub": "bin/agenthub.js"
},
+31
View File
@@ -109,6 +109,13 @@ before(async () => {
})
);
write("selfy.json", tool("selfy", { update: { kind: "self", args: ["update", "--yes"] } }));
write(
"selfnpm.json",
tool("selfnpm", {
install: { kind: "npm", package: "selfnpm-pkg" },
update: { kind: "self", args: ["update"] },
})
);
write("plain.json", tool("plain"));
exe(path.join(shellBin, "npm"));
exe(path.join(serverBin, "npm"));
@@ -241,6 +248,30 @@ describe("a system-wide npm prefix", () => {
aiTools.refresh();
}
});
it("updates an npm tool with its own updater under ~/.local through npm --prefix", async () => {
const pkg = path.join(user, "lib", "node_modules", "selfnpm-pkg", "cli.js");
exe(pkg);
fs.symlinkSync(pkg, path.join(serverBin, "selfnpm"));
aiTools.refresh();
try {
assert.deepEqual(await jobs.stepsFor("selfnpm", "update"), [
{
argv: [
path.join(shellBin, "npm"),
"install",
"-g",
"--prefix",
user,
"selfnpm-pkg@latest",
],
},
]);
} finally {
fs.rmSync(path.join(serverBin, "selfnpm"));
aiTools.refresh();
}
});
});
describe("versions", () => {
+28 -1
View File
@@ -8,7 +8,7 @@
*/
const { describe, it, beforeEach, afterEach } = require("node:test");
const assert = require("node:assert/strict");
const { desktopEnv, FORWARDED, __setExecImpl } = require("../lib/desktop-env");
const { desktopEnv, canReadClipboard, FORWARDED, __setExecImpl } = require("../lib/desktop-env");
const saved = {};
@@ -61,3 +61,30 @@ describe("desktopEnv", () => {
assert.equal(desktopEnv().WAYLAND_DISPLAY, undefined);
});
});
describe("canReadClipboard", () => {
const has =
(...names) =>
(n) =>
names.includes(n) ? `/usr/bin/${n}` : null;
it("needs wl-paste for a Wayland display, xclip for an X one", () => {
const wl = { WAYLAND_DISPLAY: "wayland-0" };
const x = { DISPLAY: ":0" };
assert.equal(canReadClipboard({ platform: "linux", env: wl, which: has("wl-paste") }), true);
assert.equal(canReadClipboard({ platform: "linux", env: wl, which: has("xclip") }), false);
assert.equal(canReadClipboard({ platform: "linux", env: x, which: has("xclip") }), true);
assert.equal(canReadClipboard({ platform: "linux", env: x, which: has() }), false);
});
it("is false with no display at all — a headless box", () => {
assert.equal(
canReadClipboard({ platform: "linux", env: {}, which: has("xclip", "wl-paste") }),
false
);
});
it("is true off Linux, where the clipboard tools are built in", () => {
assert.equal(canReadClipboard({ platform: "darwin", env: {}, which: has() }), true);
});
});
+124
View File
@@ -0,0 +1,124 @@
/**
* @file file-manager.test.js
* @description server/lib/file-manager.js: the files a file manager's Copy
* put on the OS clipboard (Windows GetFileDropList, macOS NSPasteboard, Linux
* text/uri-list / gnome-copied-files) and the ones selected in it — what a
* drag carries (Windows Shell.Application, macOS Finder) — kept only when
* they exist here, with their sizes; a missing reader is no files.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after, afterEach } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const {
clipboardFiles,
selectedFiles,
uriListPaths,
__setExecImpl,
} = require("../lib/file-manager");
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "fm-files-"));
const big = path.join(TMP, "big file.mp4");
const dir = path.join(TMP, "folder");
before(() => {
fs.writeFileSync(big, Buffer.alloc(1234));
fs.mkdirSync(dir);
});
after(() => fs.rmSync(TMP, { recursive: true, force: true }));
afterEach(() => __setExecImpl(async () => ""));
describe("uriListPaths", () => {
it("decodes file URIs and skips a gnome list's verb, comments and other hosts", () => {
assert.deepEqual(
uriListPaths("copy\nfile:///home/u/a%20b.png\r\n# c\nfile://other/x\nhttps://x/y\n"),
["/home/u/a b.png"]
);
});
});
describe("clipboardFiles", () => {
it("reads Windows' file drop list with powershell -Sta", async () => {
let seen;
__setExecImpl(async (cmd, args) => {
seen = [cmd, args];
return `${big}\r\n${dir}\r\n${path.join(TMP, "gone.txt")}\r\n`;
});
assert.deepEqual(await clipboardFiles("win32"), [
{ path: big, size: 1234, dir: false },
{ path: dir, size: null, dir: true },
]);
assert.equal(seen[0], "powershell");
assert.ok(seen[1].includes("-Sta"));
assert.match(seen[1].at(-1), /GetFileDropList/);
});
it("reads macOS file URLs through JXA", async () => {
let seen;
__setExecImpl(async (cmd, args) => {
seen = [cmd, args];
return `${big}\n`;
});
assert.deepEqual(await clipboardFiles("darwin"), [{ path: big, size: 1234, dir: false }]);
assert.equal(seen[0], "osascript");
assert.deepEqual(seen[1].slice(0, 2), ["-l", "JavaScript"]);
});
it("reads Linux text/uri-list with the desktop's display", async () => {
const saved = process.env.DISPLAY;
process.env.DISPLAY = ":0";
try {
const calls = [];
__setExecImpl(async (cmd, args, env) => {
calls.push([cmd, args.join(" "), env.DISPLAY]);
return cmd === "xclip" ? `file://${encodeURI(big)}\n` : "";
});
assert.deepEqual(await clipboardFiles("linux"), [{ path: big, size: 1234, dir: false }]);
assert.deepEqual(calls.at(-1), ["xclip", "-selection clipboard -t text/uri-list -o", ":0"]);
} finally {
if (saved === undefined) delete process.env.DISPLAY;
else process.env.DISPLAY = saved;
}
});
it("answers no files when the reader is missing or prints relative paths", async () => {
__setExecImpl(async () => "");
assert.deepEqual(await clipboardFiles("win32"), []);
__setExecImpl(async () => "relative/x.txt\n");
assert.deepEqual(await clipboardFiles("darwin"), []);
});
it("reads Explorer's selection (and the desktop's) for a drop, once per path", async () => {
let seen;
__setExecImpl(async (cmd, args) => {
seen = [cmd, args];
return `${big}\r\n${big}\r\n`;
});
assert.deepEqual(await selectedFiles("win32"), [{ path: big, size: 1234, dir: false }]);
assert.equal(seen[0], "powershell");
assert.match(seen[1].at(-1), /Shell\.Application[\s\S]*SelectedItems[\s\S]*FindWindowSW/);
});
it("reads Finder's selection on macOS, a folder with its trailing slash", async () => {
let seen;
__setExecImpl(async (cmd, args) => {
seen = [cmd, args];
return `${dir}/\n`;
});
assert.deepEqual(await selectedFiles("darwin"), [{ path: `${dir}/`, size: null, dir: true }]);
assert.equal(seen[0], "osascript");
assert.ok(seen[1].includes("repeat with a in (selection as alias list)"));
});
it("has no selection to read on Linux", async () => {
let called = false;
__setExecImpl(async () => {
called = true;
return big;
});
assert.deepEqual(await selectedFiles("linux"), []);
assert.equal(called, false);
});
});
+15
View File
@@ -82,6 +82,21 @@ describe("scope", () => {
}
});
it("narrows ownSessionWhere to the caller's own sessions whatever the role", () => {
const own = (req) => {
const w = scope.ownSessionWhere(req);
return db
.prepare(`SELECT id FROM sessions WHERE ${w.where} ORDER BY id`)
.all(...w.params)
.map((r) => r.id);
};
assert.deepEqual(own(member(1)), ["s-unstamped", "s1"]);
assert.deepEqual(own(admin), []);
assert.deepEqual(own({ user: { ...admin.user, id: 2 } }), ["s2"]);
assert.deepEqual(own(analyst), []);
assert.equal(own(local).length, 5);
});
it("answers per-id checks for lanes, sessions, agents and runs", () => {
const m = member(1);
assert.equal(scope.canSeeLane(m, 1), true);
+1 -1
View File
@@ -101,7 +101,7 @@ describe("todayCost", () => {
it("reads today's row and caches it", async () => {
const file = path.join(TMP, "cache-a.json");
assert.equal(await todayCost({ url, file }), 3.5);
assert.equal(lastUrl, `/api/pricing/cost?tz_offset=${new Date().getTimezoneOffset()}`);
assert.equal(lastUrl, `/api/pricing/cost?tz_offset=${new Date().getTimezoneOffset()}&mine=1`);
assert.equal(await todayCost({ url, file }), 3.5);
assert.equal(hits, 1);
// A different dashboard URL is a cache miss.
+8 -1
View File
@@ -67,7 +67,14 @@ async function stepsFor(id, action) {
}
if (!tool.update) throw makeErr("ENOUPDATE", `${tool.label} has no update command`);
if (!d.installed) throw makeErr("ENOTINSTALLED", `${tool.label} is not installed`);
if (tool.update.kind === "self") return [{ argv: [d.path, ...tool.update.args] }];
if (tool.update.kind === "self") {
// An npm package's own updater (`codex update`) reinstalls into npm's
// GLOBAL prefix; one we installed under ~/.local must be updated there.
if (tool.install?.kind === "npm" && (await versions.managedBy(id)) === "user") {
return [{ argv: npmArgv(tool.install.package, ["--prefix", versions.userPrefix()]) }];
}
return [{ argv: [d.path, ...tool.update.args] }];
}
if (tool.update.kind === "script") {
return [{ script: tool.install.url, redirect: tool.install.redirect }];
}
+19 -1
View File
@@ -64,4 +64,22 @@ function desktopEnv() {
return out;
}
module.exports = { desktopEnv, FORWARDED, __setExecImpl };
/**
* Whether a CLI in a pane started now can read this desktop's clipboard — what
* a native image paste (`^V`, the terminal's `lib/nativePaste.ts`) relies on.
* On Linux Claude Code shells out to `wl-paste` (Wayland) or `xclip` (X11);
* neither ships with a stock Ubuntu, and with neither — or with no display to
* reach — the `^V` pastes nothing at all, so the browser must upload instead.
* macOS and Windows read their clipboard with built-in tools.
*/
function canReadClipboard({
platform = process.platform,
env = null,
which = (name) => require("./ai-tools").which(name),
} = {}) {
if (platform !== "linux") return true;
const e = env || desktopEnv();
return !!((e.WAYLAND_DISPLAY && which("wl-paste")) || (e.DISPLAY && which("xclip")));
}
module.exports = { desktopEnv, canReadClipboard, FORWARDED, __setExecImpl };
+194
View File
@@ -0,0 +1,194 @@
/**
* @file file-manager.js
* @description The files THIS machine's file manager (Explorer, Finder,
* Nautilus/Dolphin) is handing the browser, by their own absolute paths —
* what a native terminal pastes or types for them. A browser gives a page a
* pasted or dropped file as bytes and a name only (Chrome: never its path),
* so the terminal and the prompt box would upload a copy, capped in size.
* Two sources, each answered only to a loopback browser of a local-mode
* dashboard; the client uses a path only when its name and size match the
* file the browser holds, so an SSH tunnel's browser still uploads:
* - `clipboardFiles` (`POST /api/run/clipboard-files`, a paste): what a Copy
* put on the clipboard — Windows `powershell -Sta` +
* `Clipboard.GetFileDropList()` (Claude Code reads its images the same
* way), macOS JXA over `NSPasteboard` file URLs, Linux `wl-paste`/`xclip`
* for `text/uri-list` or `x-special/gnome-copied-files`;
* - `selectedFiles` (`POST /api/run/selected-files`, a drop): a drag from
* a file manager carries the items selected in it, so its selection —
* Windows `Shell.Application` (every Explorer window, and the desktop),
* macOS Finder's `selection` (macOS asks once to let AgentHub control
* Finder). Linux file managers publish no selection: a drop there takes
* the `file://` list the browser already hands over, else uploads.
* Nothing is searched for on disk. Every reader is a fixed argv, nothing from
* the request; a missing reader or an empty answer is an empty list.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { execFile } = require("node:child_process");
const fs = require("node:fs");
const path = require("node:path");
const MAX_FILES = 100;
const TIMEOUT_MS = 4000;
let execImpl = (cmd, args, env) =>
new Promise((resolve) => {
execFile(
cmd,
args,
{ encoding: "utf8", timeout: TIMEOUT_MS, maxBuffer: 1024 * 1024, env, windowsHide: true },
(err, stdout) => resolve(err ? "" : String(stdout || ""))
);
});
/** Test seam: `(cmd, args, env) => Promise<stdout>`. */
function __setExecImpl(fn) {
execImpl = fn;
}
const WIN_SCRIPT =
"[Console]::OutputEncoding = [Text.Encoding]::UTF8; Add-Type -AssemblyName System.Windows.Forms; " +
"[System.Windows.Forms.Clipboard]::GetFileDropList() | ForEach-Object { $_ }";
const MAC_SCRIPT = [
'ObjC.import("AppKit");',
"const pb = $.NSPasteboard.generalPasteboard;",
"const urls = pb.readObjectsForClassesOptions($([$.NSURL]), $({ NSPasteboardURLReadingFileURLsOnlyKey: true }));",
"const out = [];",
"if (urls) for (let i = 0; i < urls.count; i++) out.push(urls.objectAtIndex(i).path.js);",
'out.join("\\n");',
].join("\n");
/** `file://` URIs (and a gnome list's leading `copy`/`cut`) as local paths. */
function uriListPaths(text) {
const out = [];
for (const raw of String(text || "").split(/\r?\n/)) {
const line = raw.trim();
if (!line || line.startsWith("#") || line === "copy" || line === "cut") continue;
try {
const u = new URL(line);
if (u.protocol !== "file:" || (u.host && u.host !== "localhost")) continue;
out.push(decodeURIComponent(u.pathname));
} catch {
/* not a URI */
}
}
return out;
}
/** The raw paths on the clipboard, per platform; [] when it holds none. */
async function clipboardPaths(platform = process.platform) {
if (platform === "win32") {
const out = await execImpl(
"powershell",
["-NoProfile", "-NonInteractive", "-Sta", "-Command", WIN_SCRIPT],
process.env
);
return out
.split(/\r?\n/)
.map((l) => l.trim())
.filter(Boolean);
}
if (platform === "darwin") {
const out = await execImpl("osascript", ["-l", "JavaScript", "-e", MAC_SCRIPT], process.env);
return out
.split(/\r?\n/)
.map((l) => l.trim())
.filter(Boolean);
}
// Linux: the desktop session's variables, which a user service lacks.
const env = { ...process.env, ...require("./desktop-env").desktopEnv() };
const tries = [];
if (env.WAYLAND_DISPLAY) {
tries.push(["wl-paste", ["--no-newline", "--type", "text/uri-list"]]);
tries.push(["wl-paste", ["--no-newline", "--type", "x-special/gnome-copied-files"]]);
}
if (env.DISPLAY) {
tries.push(["xclip", ["-selection", "clipboard", "-t", "text/uri-list", "-o"]]);
tries.push(["xclip", ["-selection", "clipboard", "-t", "x-special/gnome-copied-files", "-o"]]);
}
for (const [cmd, args] of tries) {
const paths = uriListPaths(await execImpl(cmd, args, env));
if (paths.length) return paths;
}
return [];
}
const WIN_SELECTION =
"[Console]::OutputEncoding = [Text.Encoding]::UTF8; $sh = New-Object -ComObject Shell.Application; " +
"foreach ($w in @($sh.Windows())) { try { foreach ($i in $w.Document.SelectedItems()) { $i.Path } } catch {} }; " +
// The desktop is no Explorer window: ShellWindows.FindWindowSW(SWC_DESKTOP).
"try { $d = $sh.Windows().FindWindowSW(0, $null, 8, [ref]0, 1); foreach ($i in $d.Document.SelectedItems()) { $i.Path } } catch {}";
const MAC_SELECTION = [
'tell application "Finder"',
'set out to ""',
"repeat with a in (selection as alias list)",
"set out to out & POSIX path of a & linefeed",
"end repeat",
"return out",
"end tell",
];
/** The paths a file manager has selected, per platform; [] when unknown. */
async function selectionPaths(platform = process.platform) {
let out = "";
if (platform === "win32") {
out = await execImpl(
"powershell",
["-NoProfile", "-NonInteractive", "-Sta", "-Command", WIN_SELECTION],
process.env
);
} else if (platform === "darwin") {
out = await execImpl(
"osascript",
MAC_SELECTION.flatMap((l) => ["-e", l]),
process.env
);
}
return out
.split(/\r?\n/)
.map((l) => l.trim())
.filter(Boolean);
}
/** `[{ path, size, dir }]` for each absolute path that exists here (`size`
* null for a folder), at most 100, without repeats. */
function existing(paths) {
const out = [];
const seen = new Set();
for (const p of paths.slice(0, MAX_FILES)) {
if (!path.isAbsolute(p) && !/^[A-Za-z]:[\\/]/.test(p)) continue;
if (seen.has(p)) continue;
seen.add(p);
try {
const st = fs.statSync(p);
if (st.isFile()) out.push({ path: p, size: st.size, dir: false });
else if (st.isDirectory()) out.push({ path: p, size: null, dir: true });
} catch {
/* gone since the copy */
}
}
return out;
}
/** The clipboard's files (a file manager's Copy); [] when it holds none. */
async function clipboardFiles(platform = process.platform) {
try {
return existing(await clipboardPaths(platform));
} catch {
return [];
}
}
/** The files selected in the file manager — what a drag from it carries;
* [] when none, or on a platform whose file manager publishes none. */
async function selectedFiles(platform = process.platform) {
try {
return existing(await selectionPaths(platform));
} catch {
return [];
}
}
module.exports = { clipboardFiles, selectedFiles, uriListPaths, __setExecImpl };
+15 -5
View File
@@ -36,7 +36,19 @@ const ownerFor = (req) => (req?.user ? req.user.id : null);
* unrestricted. `col` is the session id expression, `laneCol` its lane id.
*/
function sessionWhere(req, col = "id", laneCol = "lane_id") {
const uid = memberId(req);
return ownedSessionWhere(memberId(req), col, laneCol);
}
/**
* `sessionWhere` for the requesting user's OWN sessions, whatever their role —
* a personal figure (the status bar's Today) that must not be every user's sum
* for an admin or analyst. "1" in local mode, where there is no user.
*/
function ownSessionWhere(req, col = "id", laneCol = "lane_id") {
return ownedSessionWhere(req?.user ? req.user.id : null, col, laneCol);
}
function ownedSessionWhere(uid, col, laneCol) {
if (uid == null) return { where: "1", params: [] };
return {
where: `(${laneCol} IN (SELECT id FROM lanes WHERE owner_id = ?)
@@ -155,10 +167,7 @@ const PATH_RULES = [
[/^\/workflows\/runs\/([^/]+)/i, (req, m) => canSeeRowSession(req, "workflows", m[1], "run_id")],
[/^\/alerts\/([^/]+)\/ack/i, (req, m) => canSeeRowSession(req, "alert_events", m[1])],
// `/artifacts/<id>…`: the owner's, or a shared one (server/lib/artifacts.js).
[
/^\/artifacts\/(\d+)(?:\/|$)/i,
(req, m) => require("./artifacts").canSeeId(req, m[1]),
],
[/^\/artifacts\/(\d+)(?:\/|$)/i, (req, m) => require("./artifacts").canSeeId(req, m[1])],
[
/^\/snapshots\/([^/]+)$/i,
(req, m) => req.method !== "DELETE" || canSeeRowSession(req, "session_snapshots", m[1]),
@@ -449,6 +458,7 @@ module.exports = {
memberId,
ownerFor,
sessionWhere,
ownSessionWhere,
bySessionWhere,
laneWhere,
canSeeLane,
+65
View File
@@ -161,6 +161,71 @@ const paths = {
},
},
},
"/api/run/clipboard-files": {
post: {
tags: ["Run"],
summary: "The files a file manager's Copy put on this machine's clipboard",
operationId: "runClipboardFiles",
description:
"For a browser on this machine: the files the OS clipboard holds (Windows GetFileDropList, macOS NSPasteboard file URLs, Linux text/uri-list via wl-paste/xclip), each that exists here with its size (`null` for a folder), at most 100 — so a pasted copy is attached by its own path, with no upload and no size limit. The client uses a path only when name and size match the pasted file. 403 EREMOTE in server mode or for any non-loopback (or proxied) request.",
responses: {
200: ok("The clipboard's files; empty when it holds none or no reader is installed.", {
files: {
type: "array",
items: {
type: "object",
properties: {
path: { type: "string" },
size: { type: "integer", nullable: true },
dir: { type: "boolean" },
},
},
},
}),
403: errs("EREMOTE."),
},
},
},
"/api/run/selected-files": {
post: {
tags: ["Run"],
summary: "The files selected in this machine's file manager (what a drag carries)",
operationId: "runSelectedFiles",
description:
"For a browser on this machine: the items selected in Explorer (every window, and the desktop) or Finder — a drag from the file manager carries exactly those — each that exists here with its size (`null` for a folder), at most 100, so a dropped file is attached by its own path with no upload and no size limit. Linux file managers publish no selection: always empty there. Nothing is searched for on disk. 403 EREMOTE in server mode or for any non-loopback (or proxied) request.",
responses: {
200: ok("The selected files; empty when none or no reader is available.", {
files: {
type: "array",
items: {
type: "object",
properties: {
path: { type: "string" },
size: { type: "integer", nullable: true },
dir: { type: "boolean" },
},
},
},
}),
403: errs("EREMOTE."),
},
},
},
"/api/run/native-paste": {
get: {
tags: ["Run"],
summary: "Whether a claude pane can read this machine's clipboard itself",
operationId: "runNativePaste",
description:
"`native`: local mode, a loopback request, and on Linux a reachable display plus `wl-paste` or `xclip` — then the terminal hands a pasted screenshot to `claude` as its image-paste key instead of uploading it. `key`: that chord on this machine — `alt+v` on Windows, `ctrl+v` elsewhere (Claude Code's own bindings).",
responses: {
200: ok("The answer.", {
native: { type: "boolean" },
key: { type: "string", enum: ["ctrl+v", "alt+v"] },
}),
},
},
},
"/api/run/interrupted": {
get: {
tags: ["Run"],
+10 -2
View File
@@ -576,7 +576,14 @@ function costScope(req, res) {
res.status(400).json(BAD_TOOL);
return null;
}
return costWhere({ scope: parseScope(req), lane });
// `mine=1`: the caller's own sessions even for an admin/analyst, who would
// otherwise get every user's sum (the status bar's Today is a personal figure).
const extra = [];
if (req.query.mine === "1") {
const own = require("../lib/scope").ownSessionWhere(req, "s.id", "s.lane_id");
if (own.where !== "1") extra.push({ clause: own.where, params: own.params });
}
return costWhere({ scope: parseScope(req), lane, extra });
}
/**
@@ -625,7 +632,8 @@ function computeCost(
// / analytics endpoints so the Dashboard "total cost" tracks the selector, plus
// `?lane=<id>` (the sessions that workspace owns), `?from=&to=` (local
// dates, see computeCost) and `?date=` (one local day, its `daily_costs` too —
// the status bar's Today). `by_tool` splits the total.
// the status bar's Today) and `?mine=1` (only the caller's own sessions, even
// for an admin or analyst — server mode; a no-op locally). `by_tool` splits the total.
router.get("/cost", (req, res) => {
const rawOffset = parseInt(req.query.tz_offset, 10);
const tzModifier = Number.isFinite(rawOffset) ? `${-rawOffset} minutes` : "+0 minutes";
+44 -1
View File
@@ -116,7 +116,9 @@ const DROP_TYPES = {
const DROP_MAX_BYTES = 20 * 1024 * 1024;
// A browser on this machine (loopback, never server mode) crosses no network:
// its drop is streamed to disk, so a video or a dump is no reason to refuse.
// Chrome hands a dropped file no `file://` path, so it cannot attach by path.
// Chrome hands a dropped file no `file://` path; the client first asks for
// the file manager's own (`/selected-files`, `/clipboard-files`) and only
// uploads what that cannot place.
const DROP_LOCAL_MAX_BYTES = 2 * 1024 ** 3;
const DROP_TTL_MS = 24 * 60 * 60_000;
const localDrop = (req) => !require("../lib/auth").isServerMode() && isLocalRequest(req);
@@ -314,6 +316,47 @@ router.post("/local-paths", (req, res) => {
});
});
// The files this machine's file manager is handing the browser, by their own
// paths (server/lib/file-manager.js): a browser gives a page a pasted or
// dropped file as bytes only — Chrome never with its path — so a big file
// could not be attached. A paste reads the clipboard (`clipboard-files`), a
// drop the file manager's selection, which is what a drag from it carries
// (`selected-files`). Same rule as /local-paths: local mode and a loopback
// request only; the client uses a path only when name and size match.
function fileManagerRoute(read) {
return async (req, res) => {
if (require("../lib/auth").isServerMode() || !isLocalRequest(req)) {
return res.status(403).json({
error: { code: "EREMOTE", message: "the file manager is read only for a local browser" },
});
}
res.json({ files: await read() });
};
}
router.post(
"/clipboard-files",
fileManagerRoute(() => require("../lib/file-manager").clipboardFiles())
);
router.post(
"/selected-files",
fileManagerRoute(() => require("../lib/file-manager").selectedFiles())
);
// Whether a `claude` pane can take an image paste natively (its image-paste
// key — `^V`, Alt+V on Windows: it reads
// this machine's clipboard itself) — only for a browser on this machine, in
// local mode, and only when a clipboard reader is installed and a display is
// reachable. Otherwise the browser uploads the image, which always works.
router.get("/native-paste", (req, res) => {
const local = !require("../lib/auth").isServerMode() && isLocalRequest(req);
// `key`: the chord Claude Code binds to its image paste here — Alt+V on
// Windows (Ctrl+V there is the terminal's text paste), Ctrl+V elsewhere.
res.json({
native: local && require("../lib/desktop-env").canReadClipboard(),
key: process.platform === "win32" ? "alt+v" : "ctrl+v",
});
});
router.get("/history", (req, res) => {
let dr = null;
try {
+2 -2
View File
@@ -94,7 +94,7 @@ function cachePath() {
}
/**
* Today's cost across all sessions from the dashboard, or null. A failed fetch
* Today's cost of the pane owner's own sessions (all sessions locally), or null. A failed fetch
* is cached too, so a stopped dashboard costs one timeout per TTL, not per line.
*/
async function todayCost({ url, file = cachePath(), now = Date.now() } = {}) {
@@ -114,7 +114,7 @@ async function todayCost({ url, file = cachePath(), now = Date.now() } = {}) {
if (process.env.AGENTHUB_PANE_TOKEN)
headers["x-agenthub-pane-token"] = process.env.AGENTHUB_PANE_TOKEN;
const offset = new Date(now).getTimezoneOffset();
const res = await fetch(`${url}/api/pricing/cost?tz_offset=${offset}`, {
const res = await fetch(`${url}/api/pricing/cost?tz_offset=${offset}&mine=1`, {
headers,
signal: AbortSignal.timeout(COST_TIMEOUT_MS),
});