diff --git a/client/src/components/DocumentTitle.tsx b/client/src/components/DocumentTitle.tsx index 48ee6daf..470c91bf 100644 --- a/client/src/components/DocumentTitle.tsx +++ b/client/src/components/DocumentTitle.tsx @@ -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"); } diff --git a/client/src/components/StatusBar.tsx b/client/src/components/StatusBar.tsx index f7c5cdd2..6223c5be 100644 --- a/client/src/components/StatusBar.tsx +++ b/client/src/components/StatusBar.tsx @@ -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=` — and, on the + * messages), and today's spend — `GET /api/pricing/cost?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 diff --git a/client/src/components/__tests__/DocumentTitle.test.tsx b/client/src/components/__tests__/DocumentTitle.test.tsx index bead8e2b..afd4bffe 100644 --- a/client/src/components/__tests__/DocumentTitle.test.tsx +++ b/client/src/components/__tests__/DocumentTitle.test.tsx @@ -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 · /); }); }); diff --git a/client/src/components/__tests__/StatusBar.test.tsx b/client/src/components/__tests__/StatusBar.test.tsx index a764beee..fdfe3eab 100644 --- a/client/src/components/__tests__/StatusBar.test.tsx +++ b/client/src/components/__tests__/StatusBar.test.tsx @@ -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" diff --git a/client/src/components/run/RunSetup.tsx b/client/src/components/run/RunSetup.tsx index 111402f9..491a3b24 100644 --- a/client/src/components/run/RunSetup.tsx +++ b/client/src/components/run/RunSetup.tsx @@ -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} diff --git a/client/src/components/run/TerminalView.tsx b/client/src/components/run/TerminalView.tsx index cd992c93..b44e37ee 100644 --- a/client/src/components/run/TerminalView.tsx +++ b/client/src/components/run/TerminalView.tsx @@ -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({
{ 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( + {}} + 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(); diff --git a/client/src/components/run/__tests__/TerminalView.test.tsx b/client/src/components/run/__tests__/TerminalView.test.tsx index b3e1cc5f..761b655b 100644 --- a/client/src/components/run/__tests__/TerminalView.test.tsx +++ b/client/src/components/run/__tests__/TerminalView.test.tsx @@ -10,9 +10,18 @@ * @author Nguyễn Ngọc Trí Vĩ */ 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 => Promise.resolve({ files: [] })); +const selectedFilesMock = vi.fn((): Promise => 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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. diff --git a/client/src/i18n/locales/en/nav.json b/client/src/i18n/locales/en/nav.json index 0d52a30f..44985b02 100644 --- a/client/src/i18n/locales/en/nav.json +++ b/client/src/i18n/locales/en/nav.json @@ -88,5 +88,6 @@ "now": "now", "users": "Users", "admin": "Administration", - "files": "Files" + "files": "Files", + "snapshot": "Snapshot" } diff --git a/client/src/i18n/locales/en/run.json b/client/src/i18n/locales/en/run.json index f0de4388..18ad7610 100644 --- a/client/src/i18n/locales/en/run.json +++ b/client/src/i18n/locales/en/run.json @@ -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" }, diff --git a/client/src/i18n/locales/en/settings.json b/client/src/i18n/locales/en/settings.json index 9ce44eee..54c81377 100644 --- a/client/src/i18n/locales/en/settings.json +++ b/client/src/i18n/locales/en/settings.json @@ -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", diff --git a/client/src/i18n/locales/vi/nav.json b/client/src/i18n/locales/vi/nav.json index 29a49e3c..853a4a11 100644 --- a/client/src/i18n/locales/vi/nav.json +++ b/client/src/i18n/locales/vi/nav.json @@ -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" } diff --git a/client/src/i18n/locales/vi/run.json b/client/src/i18n/locales/vi/run.json index 3fae175b..9e1c0dcd 100644 --- a/client/src/i18n/locales/vi/run.json +++ b/client/src/i18n/locales/vi/run.json @@ -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" }, diff --git a/client/src/i18n/locales/vi/settings.json b/client/src/i18n/locales/vi/settings.json index 87d6b744..04e4d005 100644 --- a/client/src/i18n/locales/vi/settings.json +++ b/client/src/i18n/locales/vi/settings.json @@ -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", diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts index 5fb9891a..6630ebaf 100644 --- a/client/src/lib/api.ts +++ b/client/src/lib/api.ts @@ -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("/run", { method: "POST", body: JSON.stringify(args) }), diff --git a/client/src/lib/nativePaste.ts b/client/src/lib/nativePaste.ts index 8840952d..c7070d9b 100644 --- a/client/src/lib/nativePaste.ts +++ b/client/src/lib/nativePaste.ts @@ -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ĩ */ +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 | 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"; +} diff --git a/client/src/lib/promptAttach.ts b/client/src/lib/promptAttach.ts index 1fd862d2..6ff6257d 100644 --- a/client/src/lib/promptAttach.ts +++ b/client/src/lib/promptAttach.ts @@ -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ĩ */ @@ -144,6 +146,53 @@ export async function sameFilesHere(paths: string[], files: File[]): Promise { + 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(/[\\/]$/, "") diff --git a/docs/API.md b/docs/API.md index e5f0c13b..4e61e20f 100644 --- a/docs/API.md +++ b/docs/API.md @@ -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=&sources=&lane=&tool=&from=&to=&date= +GET /api/pricing/cost?tz_offset=&sources=&lane=&tool=&from=&to=&date=&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=` 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=` 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=` 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=` 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=` 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=` 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 /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 -; 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 -; 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:[…]} (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 `/ai-tool-config-backups//` 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 `/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 @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 `; `self` runs ` ...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 `/runtime/ai-tool-icons/`, served with `nosniff` and `Content-Security-Policy: sandbox`; an `` passes the dashboard token as `?token=`. The client shows its own shipped mark instead where it has one (`client/public/ai-tools/.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 @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 `; `self` runs ` ...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 `/runtime/ai-tool-icons/`, served with `nosniff` and `Content-Security-Policy: sandbox`; an `` passes the dashboard token as `?token=`. The client shows its own shipped mark instead where it has one (`client/public/ai-tools/.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 diff --git a/docs/STATUSLINE.md b/docs/STATUSLINE.md index d228e9c1..340dcd7f 100644 --- a/docs/STATUSLINE.md +++ b/docs/STATUSLINE.md @@ -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=` → today's `daily_costs` row | +| `today $…` | your sessions today, from the dashboard: `GET /api/pricing/cost?tz_offset=&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 `/agenthub-statusline-.json` — a failed fetch is cached too, so a diff --git a/openapi.yaml b/openapi.yaml index f5f469db..ffde9307 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -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: diff --git a/server/__tests__/ai-tool-jobs.test.js b/server/__tests__/ai-tool-jobs.test.js index 00232572..3fde612e 100644 --- a/server/__tests__/ai-tool-jobs.test.js +++ b/server/__tests__/ai-tool-jobs.test.js @@ -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", () => { diff --git a/server/__tests__/desktop-env.test.js b/server/__tests__/desktop-env.test.js index 68ce2cb6..8f10afeb 100644 --- a/server/__tests__/desktop-env.test.js +++ b/server/__tests__/desktop-env.test.js @@ -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); + }); +}); diff --git a/server/__tests__/file-manager.test.js b/server/__tests__/file-manager.test.js new file mode 100644 index 00000000..971e384f --- /dev/null +++ b/server/__tests__/file-manager.test.js @@ -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ĩ + */ +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); + }); +}); diff --git a/server/__tests__/scope.test.js b/server/__tests__/scope.test.js index 64462a5f..13559fd7 100644 --- a/server/__tests__/scope.test.js +++ b/server/__tests__/scope.test.js @@ -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); diff --git a/server/__tests__/statusline.test.js b/server/__tests__/statusline.test.js index e6f8045e..73233d8f 100644 --- a/server/__tests__/statusline.test.js +++ b/server/__tests__/statusline.test.js @@ -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. diff --git a/server/lib/ai-tool-jobs.js b/server/lib/ai-tool-jobs.js index 4d027d9d..fe867cea 100644 --- a/server/lib/ai-tool-jobs.js +++ b/server/lib/ai-tool-jobs.js @@ -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 }]; } diff --git a/server/lib/desktop-env.js b/server/lib/desktop-env.js index 5608491d..21657b58 100644 --- a/server/lib/desktop-env.js +++ b/server/lib/desktop-env.js @@ -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 }; diff --git a/server/lib/file-manager.js b/server/lib/file-manager.js new file mode 100644 index 00000000..3669c7c1 --- /dev/null +++ b/server/lib/file-manager.js @@ -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ĩ + */ + +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`. */ +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 }; diff --git a/server/lib/scope.js b/server/lib/scope.js index 59082a99..6bc1fd3c 100644 --- a/server/lib/scope.js +++ b/server/lib/scope.js @@ -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/…`: 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, diff --git a/server/openapi-extra/ops-more.js b/server/openapi-extra/ops-more.js index 83d3d4e7..912f2f58 100644 --- a/server/openapi-extra/ops-more.js +++ b/server/openapi-extra/ops-more.js @@ -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"], diff --git a/server/routes/pricing.js b/server/routes/pricing.js index 4fff8126..c324026a 100644 --- a/server/routes/pricing.js +++ b/server/routes/pricing.js @@ -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=` (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"; diff --git a/server/routes/run.js b/server/routes/run.js index 4025f21f..5fef6050 100644 --- a/server/routes/run.js +++ b/server/routes/run.js @@ -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 { diff --git a/statusline/statusline.js b/statusline/statusline.js index 32d6f37e..b1151864 100644 --- a/statusline/statusline.js +++ b/statusline/statusline.js @@ -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), });