From ecbbb4dd8ffb125ed5bc977385201cc378ff99b6 Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Fri, 16 Oct 2026 10:05:03 +0700 Subject: [PATCH] perf: faster plugin updates, half-size first bundle, images from the chat attach on Windows - Chat view: an attached image's path is pasted on its own after the text (promptForPane). claude turns a paste that is only an image path into [Image #n], but left a Windows path inside a paste of text as a plain path. Other files and @-mentions stay in the text as before. - Plugin bootstrap: depsHash covers only what npm installs (no release version), so a version bump no longer reinstalls runtime deps; the client's installed tree is kept while its manifests are unchanged (no npm ci per update); the user-side build runs vite only (tsc -b cost ~25 s / ~900 MB); clientBuild is recorded only after a build that did not fail, so a failed one is retried; npm output goes to runtime/npm-install.log. - Server: a port held by another program ends with a clear message and exit 1 instead of an uncaught 'error' stack trace. - Client: the onboarding wizard is lazy and rendered only before onboarding; usePrefersReducedMotion moved to hooks/ so PetSettings no longer pulls the Sagi chat/pane stack into the entry chunk (884 KB -> 431 KB, gzip 268 -> 133 KB). --- SETUP.md | 16 ++--- client/src/components/Layout.tsx | 17 ++++- client/src/components/Sagi/Sagi.tsx | 18 +---- client/src/components/run/PaneChat.tsx | 15 +++-- client/src/components/run/PaneGrid.tsx | 6 +- client/src/components/run/RunSetup.tsx | 17 +++++ client/src/components/run/TerminalView.tsx | 8 ++- .../run/__tests__/PaneChat.test.tsx | 11 ++-- .../run/__tests__/RunSetup.test.tsx | 21 ++++++ .../src/components/settings/PetSettings.tsx | 2 +- client/src/hooks/usePrefersReducedMotion.ts | 27 ++++++++ client/src/lib/panePaste.ts | 14 ++-- docs/PLUGINS.md | 8 +-- scripts/plugin-bootstrap.js | 62 ++++++++++++++---- scripts/plugin-open.js | 58 +++++++++++++---- server/__tests__/plugin-bootstrap.test.js | 65 +++++++++++++++++-- server/index.js | 37 +++++++++-- 17 files changed, 313 insertions(+), 89 deletions(-) create mode 100644 client/src/hooks/usePrefersReducedMotion.ts diff --git a/SETUP.md b/SETUP.md index be59022c..0b402c14 100644 --- a/SETUP.md +++ b/SETUP.md @@ -401,18 +401,16 @@ This was a known timezone bug (fixed in current version). If you are still seein ### Port 4820 already in use +When another program holds the port, the server stops with +`Port 4820 is already in use by another program…` (a running AgentHub there is +detected and simply reused). Pick another port: + ```bash -DASHBOARD_PORT=4821 npm run dev +DASHBOARD_PORT=4821 npm start # or: DASHBOARD_PORT=4821 npm run dev ``` -Then update the Vite proxy in `client/vite.config.ts`: - -```ts -proxy: { - "/api": "http://localhost:4821", - "/ws": { target: "ws://localhost:4821", ws: true } -} -``` +The Vite dev proxy and the Claude Code hooks follow the port on their own +(`client/vite.config.ts` reads `DASHBOARD_PORT`; the server publishes the port it bound). And make sure Claude Code posts hooks to the new port: diff --git a/client/src/components/Layout.tsx b/client/src/components/Layout.tsx index 3865d673..31e0f668 100644 --- a/client/src/components/Layout.tsx +++ b/client/src/components/Layout.tsx @@ -76,7 +76,7 @@ * * ----------------------------------------------------------------------------- */ -import { useState, useCallback, useEffect, useSyncExternalStore } from "react"; +import { lazy, Suspense, useState, useCallback, useEffect, useSyncExternalStore } from "react"; import { Outlet, useLocation } from "react-router-dom"; import { Menu } from "lucide-react"; import { useTranslation } from "react-i18next"; @@ -85,9 +85,15 @@ import { UpdateNotifier } from "./UpdateNotifier"; import { InterruptedSessionsPrompt } from "./lanes/InterruptedSessionsPrompt"; import { SagiMount } from "./Sagi/SagiMount"; import { StatusBar } from "./StatusBar"; -import { OnboardingWizard } from "./onboarding/OnboardingWizard"; +import { useOnboarding } from "../lib/userPrefs"; import { OnboardingTour } from "./onboarding/OnboardingTour"; +// Loaded only for an account that has not finished onboarding: the wizard +// carries the appearance, pet and notification settings screens. +const OnboardingWizard = lazy(() => + import("./onboarding/OnboardingWizard").then((m) => ({ default: m.OnboardingWizard })) +); + const WIDTH_KEY = "agenthub.sidebar.width"; /** The default width, and the narrowest the edge drags to. */ export const SIDEBAR_MIN = 240; @@ -136,6 +142,7 @@ export function Layout({ wsConnected }: LayoutProps) { const { t } = useTranslation("nav"); const [collapsed, setCollapsed] = useState(loadCollapsed); const [connectionOpen, setConnectionOpen] = useState(false); + const onboarding = useOnboarding(); const [updateAvailable, setUpdateAvailable] = useState(false); const drawer = useMedia(DRAWER_QUERY); const [drawerOpen, setDrawerOpen] = useState(false); @@ -177,7 +184,11 @@ export function Layout({ wsConnected }: LayoutProps) { - + {onboarding.loaded && !onboarding.onboardedAt && ( + + + + )} {drawer && drawerOpen && (
- typeof window !== "undefined" && - window.matchMedia?.("(prefers-reduced-motion: reduce)").matches - ); - useEffect(() => { - const mq = window.matchMedia?.("(prefers-reduced-motion: reduce)"); - if (!mq) return; - const onChange = () => setReduced(mq.matches); - mq.addEventListener?.("change", onChange); - return () => mq.removeEventListener?.("change", onChange); - }, []); - return reduced; -} - /** * Fixed-position wrapper that places its content next to the avatar and clamps * it inside the viewport. It measures itself (and re-measures on content/size diff --git a/client/src/components/run/PaneChat.tsx b/client/src/components/run/PaneChat.tsx index 4b3da887..8a16bf3f 100644 --- a/client/src/components/run/PaneChat.tsx +++ b/client/src/components/run/PaneChat.tsx @@ -59,7 +59,13 @@ import { useTranslation } from "react-i18next"; import { ArrowUp, FileText, Square, SquareCheck } from "lucide-react"; import { isSubmitEnter } from "../../lib/keys"; import { ConversationView } from "../conversation/ConversationView"; -import { ModelMenu, PromptEditor, promptWithImages, type PromptImage } from "./RunSetup"; +import { + ModelMenu, + PromptEditor, + promptForPane, + promptWithImages, + type PromptImage, +} from "./RunSetup"; import { ChatAddMenu } from "./ChatAddMenu"; import { addAttachments, dragAttachments } from "../../lib/promptAttach"; import { readChatDraft, writeChatDraft } from "../../lib/chatDraft"; @@ -491,7 +497,7 @@ export const PaneChat = memo(function PaneChat({ const [notStarted, setNotStarted] = useState(false); // A control is a command typed into the pane, like the user would. - const command = async (line: string) => { + const command = async (line: string, files: string[] = []) => { if ( notStarted && !(await confirmDialog({ @@ -501,14 +507,15 @@ export const PaneChat = memo(function PaneChat({ })) ) return false; - const ok = submitToRun(runId, line); + const ok = submitToRun(runId, line, files); if (!ok) toast.error(t("chat.notSent")); return ok; }; const send = async () => { if (!text || uploading) return; - if (!(await command(text))) return; + const out = promptForPane(prompt, images); + if (!(await command(out.text, out.files))) return; setFollow((n) => n + 1); setPrompt(""); images.forEach((i) => i.url && URL.revokeObjectURL(i.url)); diff --git a/client/src/components/run/PaneGrid.tsx b/client/src/components/run/PaneGrid.tsx index b63f7116..6d7f090f 100644 --- a/client/src/components/run/PaneGrid.tsx +++ b/client/src/components/run/PaneGrid.tsx @@ -73,7 +73,7 @@ import { RunDot, type RunState } from "./ConsoleTabs"; import { setSessionChat, useSessionChat } from "../../lib/chatUi"; import { readChatDraft, writeChatDraft } from "../../lib/chatDraft"; import { pasteToRun, takeRunInput } from "../../lib/panePaste"; -import { promptWithImages } from "./RunSetup"; +import { promptForPane } from "./RunSetup"; export const PANE_DRAG_TYPE = "application/x-agenthub-pane"; /** The slot being dragged: `dataTransfer` data cannot be read during dragover. */ @@ -246,8 +246,8 @@ const ITEM = * the CLI's input box, without Enter. */ export function showTerminal(runId: string, sessionId: string) { const draft = readChatDraft(sessionId); - const text = promptWithImages(draft.prompt, draft.images); - if (text && pasteToRun(runId, text)) writeChatDraft(sessionId, "", []); + const { text, files } = promptForPane(draft.prompt, draft.images); + if ((text || files.length) && pasteToRun(runId, text, files)) writeChatDraft(sessionId, "", []); setSessionChat(sessionId, false); } diff --git a/client/src/components/run/RunSetup.tsx b/client/src/components/run/RunSetup.tsx index 1b917266..a4f91b4c 100644 --- a/client/src/components/run/RunSetup.tsx +++ b/client/src/components/run/RunSetup.tsx @@ -208,6 +208,23 @@ export function promptWithImages(text: string, images: PromptImage[]): string { return [text.trim(), ...images.map((i) => i.path)].filter(Boolean).join(" "); } +const IMAGE_PATH = /^[^@].*\.(png|jpe?g|gif|webp)$/i; + +/** The prompt as pasted into a live pane: the text with every non-image file + * in it (as `promptWithImages`), and the images' paths apart — each goes in + * a paste of its own, the one shape `claude` turns into `[Image #n]` on every + * platform (a Windows path inside a paste of text stayed a plain path). */ +export function promptForPane( + text: string, + images: PromptImage[] +): { text: string; files: string[] } { + const isImage = (i: PromptImage) => IMAGE_PATH.test(i.path); + return { + text: promptWithImages(text, images.filter((i) => !isImage(i))), + files: images.filter(isImage).map((i) => i.path), + }; +} + /** A plain-text paste longer than this is attached as `pasted-text.txt`. */ export const PASTE_AS_FILE_CHARS = 5000; diff --git a/client/src/components/run/TerminalView.tsx b/client/src/components/run/TerminalView.tsx index 80d1ba9c..378a2e3e 100644 --- a/client/src/components/run/TerminalView.tsx +++ b/client/src/components/run/TerminalView.tsx @@ -666,8 +666,12 @@ export const TerminalView = memo(function TerminalView({ () => registerPane( runId, - (text, submit) => { - termRef.current?.paste(text); + (text, submit, files = []) => { + const term = termRef.current; + // The text, then each image's path in a paste of its own — the one + // shape `claude` turns into `[Image #n]` on every platform. + if (text) term?.paste(files.length ? `${text} ` : text); + files.forEach((f, i) => term?.paste(i < files.length - 1 ? `${f} ` : f)); // Enter a beat after the paste, so a TUI that times its paste // detection never reads it as a newline inside the paste. A submit // comes from the pane's Chat view, which keeps the keyboard. diff --git a/client/src/components/run/__tests__/PaneChat.test.tsx b/client/src/components/run/__tests__/PaneChat.test.tsx index 6dd54bcd..abfada18 100644 --- a/client/src/components/run/__tests__/PaneChat.test.tsx +++ b/client/src/components/run/__tests__/PaneChat.test.tsx @@ -267,7 +267,7 @@ describe("PaneChat — model, effort and permission mode", () => { renderChat(); fireEvent.click(screen.getByRole("button", { name: /Opus/ })); fireEvent.click(await screen.findByRole("menuitemradio", { name: /Sonnet/ })); - expect(submitToRun).toHaveBeenCalledWith("agenthub-lane-1", "/model sonnet"); + expect(submitToRun).toHaveBeenCalledWith("agenthub-lane-1", "/model sonnet", []); }); it("shows the mode the pane shows, and asks the server to step it", async () => { @@ -430,7 +430,7 @@ describe("PaneChat — the unsent prompt", () => { expect(screen.getByPlaceholderText("Write a message…")).toHaveValue("half typed"); expect(screen.getByText("a.ts")).toBeInTheDocument(); fireEvent.click(screen.getByTestId("pane-chat-send")); - expect(submitToRun).toHaveBeenCalledWith("agenthub-lane-1", "half typed @src/a.ts"); + expect(submitToRun).toHaveBeenCalledWith("agenthub-lane-1", "half typed @src/a.ts", []); await waitFor(() => expect(localStorage.getItem("agenthub.chat.draft.s1")).toBeNull()); }); @@ -462,7 +462,8 @@ describe("PaneChat — the unsent prompt", () => { fireEvent.click(screen.getByTestId("pane-chat-send")); expect(submitToRun).toHaveBeenCalledWith( "agenthub-lane-1", - 'read @"/home/u/My Docs/spec.md" @/home/u/b.ts' + 'read @"/home/u/My Docs/spec.md" @/home/u/b.ts', + [] ); }); @@ -490,7 +491,7 @@ describe("PaneChat — the unsent prompt", () => { expect(box).toHaveValue("hello"); confirmDialog.mockResolvedValueOnce(true); fireEvent.click(screen.getByTestId("pane-chat-send")); - await waitFor(() => expect(submitToRun).toHaveBeenCalledWith("agenthub-lane-1", "hello")); + await waitFor(() => expect(submitToRun).toHaveBeenCalledWith("agenthub-lane-1", "hello", [])); }); it("sends without asking once the session has started", async () => { @@ -499,7 +500,7 @@ describe("PaneChat — the unsent prompt", () => { renderChat(); fireEvent.change(screen.getByPlaceholderText("Write a message…"), { target: { value: "hi" } }); fireEvent.click(screen.getByTestId("pane-chat-send")); - await waitFor(() => expect(submitToRun).toHaveBeenCalledWith("agenthub-lane-1", "hi")); + await waitFor(() => expect(submitToRun).toHaveBeenCalledWith("agenthub-lane-1", "hi", [])); expect(confirmDialog).not.toHaveBeenCalled(); }); }); diff --git a/client/src/components/run/__tests__/RunSetup.test.tsx b/client/src/components/run/__tests__/RunSetup.test.tsx index 820f4f45..07cb8dc0 100644 --- a/client/src/components/run/__tests__/RunSetup.test.tsx +++ b/client/src/components/run/__tests__/RunSetup.test.tsx @@ -24,6 +24,7 @@ import { ModelPicker, PASTE_AS_FILE_CHARS, PromptEditor, + promptForPane, promptWithImages, type PromptImage, } from "../RunSetup"; @@ -586,3 +587,23 @@ describe("ModelMenu", () => { expect(onChange).toHaveBeenCalledWith("claude-sonnet-5-5"); }); }); + +describe("promptForPane — images apart, every other file in the text", () => { + it("keeps @-mentions and other files in the text, and takes images out", () => { + // A Windows path inside a paste of text stayed a plain path in claude; a + // paste that is only the path becomes [Image #n]. + const images: PromptImage[] = [ + { path: "C:\\Users\\u\\.claude\\agenthub\\drops\\f326fc666d3bdd92-image.png", name: "image.png" }, + { path: "/data/drops/0123456789abcdef-log.txt", name: "log.txt" }, + { path: '@"/home/u/My Docs/a.PNG"', name: "a.PNG" }, + { path: "/data/drops/0123456789abcdef.JPG", name: "b.jpg" }, + ]; + expect(promptForPane(" look ", images)).toEqual({ + text: 'look /data/drops/0123456789abcdef-log.txt @"/home/u/My Docs/a.PNG"', + files: [ + "C:\\Users\\u\\.claude\\agenthub\\drops\\f326fc666d3bdd92-image.png", + "/data/drops/0123456789abcdef.JPG", + ], + }); + }); +}); diff --git a/client/src/components/settings/PetSettings.tsx b/client/src/components/settings/PetSettings.tsx index 753b95c7..da941c9d 100644 --- a/client/src/components/settings/PetSettings.tsx +++ b/client/src/components/settings/PetSettings.tsx @@ -28,7 +28,7 @@ import { SettingsRow, SettingsSwitchRow } from "./SettingsRow"; import { SagiAvatar } from "../Sagi/SagiAvatar"; import { PetSprite } from "../Sagi/PetSprite"; import { PET_SIZE, sagiPrefs } from "../Sagi/prefs"; -import { usePrefersReducedMotion } from "../Sagi/Sagi"; +import { usePrefersReducedMotion } from "../../hooks/usePrefersReducedMotion"; import { confirmDialog } from "../../lib/confirm"; import { toast } from "../../lib/toast"; import { usePets, useWornPet } from "../Sagi/usePets"; diff --git a/client/src/hooks/usePrefersReducedMotion.ts b/client/src/hooks/usePrefersReducedMotion.ts new file mode 100644 index 00000000..42ba7626 --- /dev/null +++ b/client/src/hooks/usePrefersReducedMotion.ts @@ -0,0 +1,27 @@ +/** + * @file usePrefersReducedMotion.ts + * @description The OS "reduce motion" preference, followed live. Its own + * module so a settings screen can read it without importing the pet overlay + * (`components/Sagi/Sagi.tsx`), whose chat and pane UI would otherwise be + * pulled into every page's first bundle. + * @author Nguyễn Ngọc Trí Vĩ + */ + +import { useEffect, useState } from "react"; + +/** The OS "reduce motion" preference, followed live. */ +export function usePrefersReducedMotion(): boolean { + const [reduced, setReduced] = useState( + () => + typeof window !== "undefined" && + window.matchMedia?.("(prefers-reduced-motion: reduce)").matches + ); + useEffect(() => { + const mq = window.matchMedia?.("(prefers-reduced-motion: reduce)"); + if (!mq) return; + const onChange = () => setReduced(mq.matches); + mq.addEventListener?.("change", onChange); + return () => mq.removeEventListener?.("change", onChange); + }, []); + return reduced; +} diff --git a/client/src/lib/panePaste.ts b/client/src/lib/panePaste.ts index 39c81249..2acf8841 100644 --- a/client/src/lib/panePaste.ts +++ b/client/src/lib/panePaste.ts @@ -14,6 +14,9 @@ * terminal moves the unsent prompt across (`takeRunInput` reads the TUI's * input box off the screen and empties it; the chat's draft is pasted back * without Enter), so the half-typed text is never left behind on the other side. + * Attached images' paths go after the text, each in a paste of its own: `claude` + * turns a paste that is only an image's path into `[Image #n]`, but left a + * Windows path (`C:\…\drops\…png`) inside a paste of text as a plain path. * The Chat view also answers a menu the CLI asks with (`parsePaneMenu` reads * it off the screen; `chooseRunOption`/`answerRunMenu` press its digit, the * arrows and Enter, or type into its "Type something" row). @@ -24,7 +27,8 @@ export type PaneScreen = { rows: string[]; cols: number }; interface Pane { - paste: (text: string, submit?: boolean) => void; + /** `files`: image paths pasted after `text`, one paste each. */ + paste: (text: string, submit?: boolean, files?: string[]) => void; visible: () => boolean; key?: (data: string) => void; /** `menuOnly`: null at once unless a row could be a menu's cursor row. */ @@ -62,19 +66,19 @@ function ofLane(runId: string, laneId: number): boolean { /** Pastes `text` into the pane of `runId` when it is mounted and on screen; * false otherwise (not mounted yet, or hidden). */ -export function pasteToRun(runId: string, text: string): boolean { +export function pasteToRun(runId: string, text: string, files: string[] = []): boolean { const p = panes.get(runId); if (!p?.visible()) return false; - p.paste(text); + p.paste(text, false, files); return true; } /** Sends `text` as a message to the pane of `runId`: a paste, then Enter. * False when the pane is not mounted or not attached. */ -export function submitToRun(runId: string, text: string): boolean { +export function submitToRun(runId: string, text: string, files: string[] = []): boolean { const p = panes.get(runId); if (!p?.visible()) return false; - p.paste(text, true); + p.paste(text, true, files); return true; } diff --git a/docs/PLUGINS.md b/docs/PLUGINS.md index 1967a8cb..c02d2ad1 100644 --- a/docs/PLUGINS.md +++ b/docs/PLUGINS.md @@ -126,10 +126,10 @@ waits on an install: 2. **Superseded check** — if Claude Code records a DIFFERENT build of this plugin as installed, this one logs a line and does nothing else (see [A retired build never acts](#a-retired-build-never-acts)). 3. **Node gate** — refuse below 22.5 with one line (see Prerequisites). 4. **Lock** — atomic `mkdir` lock holding the PID; reclaimed when the owner is dead or the lock is older than 10 minutes, so two sessions cannot race the install or spawn two servers. -5. **Install** — `npm install --omit=dev --ignore-scripts` into the runtime dir, run as the npm bundled with the node running the bootstrap (`npmCommand`: its `npm-cli.js` beside `process.execPath`, no shell, that node's directory first on PATH; `npm` on PATH only when that node ships none) — the update chain runs with the dashboard server's environment, whose PATH need not reach `npm.cmd` on Windows. The UI build below and the doctor's npm row use the same npm (never into the plugin cache, which is garbage-collected and replaced on every update). `--ignore-scripts` keeps the root `postinstall` from pulling the whole Vite client toolchain. +5. **Install** — only when the dependencies changed (`depsHash` covers what npm installs — the dependency lists and the lockfile's package tree — never the release's own `version`, so a plain version bump reinstalls nothing): `npm install --omit=dev --ignore-scripts` into the runtime dir, output in `runtime/npm-install.log`, run as the npm bundled with the node running the bootstrap (`npmCommand`: its `npm-cli.js` beside `process.execPath`, no shell, that node's directory first on PATH; `npm` on PATH only when that node ships none) — the update chain runs with the dashboard server's environment, whose PATH need not reach `npm.cmd` on Windows. The UI build below and the doctor's npm row use the same npm (never into the plugin cache, which is garbage-collected and replaced on every update). `--ignore-scripts` keeps the root `postinstall` from pulling the whole Vite client toolchain. 6. **Legacy hook cleanup** — see above. 7. **CLI** — write the `~/.local/bin/agenthub` launcher, never clobbering a `agenthub` the bootstrap did not write. It pins the node that ran the bootstrap and falls back to `node` on `PATH` when that binary is gone (a Homebrew Cellar or nvm path deleted by an upgrade). -8. **UI build** — `client/` is copied into the runtime dir and built there (`npm ci --include=dev --ignore-scripts`, falling back to `npm install` with the same flags, then `tsc -b` and `vite build` run by the bootstrap's own `node` — the client's `build` script without `npm run`), landing in `runtime/client-dist` — the same directory the server serves from. Nothing in it looks `node` up on PATH: on Windows an install script or a `.bin/*.cmd` shim runs through cmd.exe, and the server's PATH may have no `node` (esbuild's postinstall failed there with "'node' is not recognized" and the update kept the old UI); esbuild, the only dependency with an install script, finds its binary without it. `--include=dev` is required, not tidy: the entire build toolchain (`vite`, `typescript`, `@vitejs/plugin-react`, `tailwindcss`, `@types/*`) is a devDependency, and npm reads an inherited `NODE_ENV=production` as `--omit=dev`. Every bootstrap descended from the running server carries that env — the self-update chain, and any `SessionStart` fired by a `claude` inside a tmux pane the server started — so without the flag the install drops `vite` and the build dies on missing types. Skipped when a bundle for the current version already exists; a failure here does not fail the bootstrap (the API still works, and `/agenthub-open` can retry) but does mean `/run` 404s until it's fixed — and when a bundle from the PREVIOUS version is still on disk the failure is quieter still: the dashboard keeps serving the old UI against the new API, visible only as the Settings page's `UI build vX (rebuild client to match)` line. +8. **UI build** — `client/` is copied into the runtime dir and built there (`npm ci --include=dev --ignore-scripts`, falling back to `npm install` with the same flags — skipped while `client/package.json` and its lockfile are unchanged, because the installed tree in `runtime/client-src/node_modules` is kept with a `.agenthub-deps` stamp; a failed build drops the stamp so the next one installs from clean — then `vite build` run by the bootstrap's own `node`: the vite half of the client's `build` script without `npm run`. The `tsc -b` half is the release's job, not every user's: it emits nothing the bundle needs and cost ~25 s and ~900 MB per update), landing in `runtime/client-dist` — the same directory the server serves from. Nothing in it looks `node` up on PATH: on Windows an install script or a `.bin/*.cmd` shim runs through cmd.exe, and the server's PATH may have no `node` (esbuild's postinstall failed there with "'node' is not recognized" and the update kept the old UI); esbuild, the only dependency with an install script, finds its binary without it. `--include=dev` is required, not tidy: the entire build toolchain (`vite`, `typescript`, `@vitejs/plugin-react`, `tailwindcss`, `@types/*`) is a devDependency, and npm reads an inherited `NODE_ENV=production` as `--omit=dev`. Every bootstrap descended from the running server carries that env — the self-update chain, and any `SessionStart` fired by a `claude` inside a tmux pane the server started — so without the flag the install drops `vite` and the build dies on missing types. Skipped when a bundle for the current version already exists (`clientBuild` in `state.json`, recorded only after a build that did not fail, so the next session retries a failed one); a failure here does not fail the bootstrap (the API still works, and `/agenthub-open` can retry) but does mean `/run` 404s until it's fixed — and when a bundle from the PREVIOUS version is still on disk the failure is quieter still: the dashboard keeps serving the old UI against the new API, visible only as the Settings page's `UI build vX (rebuild client to match)` line. 9. **Server** — when the user has turned **Start AgentHub automatically** on (Settings → System; OFF by default), install/refresh the `agenthub.service` unit on Linux — on macOS with a GUI login, the @@ -149,8 +149,8 @@ waits on an install: The first run takes a few minutes — most of it is the UI build, which is what makes client-only routes (`http://localhost:4820/run`, and every other page) work the moment `claude` starts, with no manual `/agenthub-open` step. Progress -goes to `~/.claude/agenthub/runtime/bootstrap.log`; the UI build's own -output goes to `client-build.log` next to it; the server's own output goes to +goes to `~/.claude/agenthub/runtime/bootstrap.log`; npm's own output goes to +`npm-install.log` and the UI build's to `client-build.log` next to it; the server's own output goes to `server.log`. ### A retired build never acts diff --git a/scripts/plugin-bootstrap.js b/scripts/plugin-bootstrap.js index 2c704be9..4e1d341f 100644 --- a/scripts/plugin-bootstrap.js +++ b/scripts/plugin-bootstrap.js @@ -138,14 +138,38 @@ function serverIsLive() { return livePids().length > 0; } +/** + * What npm installs from a manifest: its dependency lists, or the lockfile's + * package tree — never the release's own `version`, which both files carry. + * Hashing the whole files reinstalled every dependency on each update (minutes + * on a slow link) when the bump changed nothing npm would fetch. + */ +function installedPart(file, text) { + const json = JSON.parse(text); + if (file === "package.json") { + const { dependencies, optionalDependencies, overrides } = json; + return JSON.stringify({ dependencies, optionalDependencies, overrides }); + } + const { "": own, ...packages } = json.packages || {}; + const { version: _version, ...rest } = own || {}; + return JSON.stringify({ own: rest, packages }); +} + /** Hash of the dependency manifest — changes mean the runtime tree must be reinstalled. */ function depsHash(root = PLUGIN_ROOT) { const h = crypto.createHash("sha256"); for (const f of ["package.json", "package-lock.json"]) { + let text; try { - h.update(fs.readFileSync(path.join(root, f))); + text = fs.readFileSync(path.join(root, f), "utf8"); } catch { h.update(f); // absent counts as its own state + continue; + } + try { + h.update(installedPart(f, text)); + } catch { + h.update(text); // not JSON npm could read either: the bytes decide } } return h.digest("hex").slice(0, 16); @@ -422,13 +446,22 @@ function installDeps(rt = runtimeDir(), root = PLUGIN_ROOT) { if (fs.existsSync(src)) fs.copyFileSync(src, path.join(rt, f)); } const npm = npmCommand(["install", "--omit=dev", "--ignore-scripts", "--no-audit", "--no-fund"]); - const res = spawnSync(npm.file, npm.args, { - cwd: rt, - stdio: "inherit", - env: npm.env, - shell: npm.shell, - }); - return res.status === 0; + // npm's output goes to a log: the worker's own stdout is discarded, so + // inheriting it threw away the one explanation of a failed install. + const logPath = path.join(rt, "npm-install.log"); + require("./log-rotate").rotateIfLarge(logPath); + const logFd = fs.openSync(logPath, "a"); + try { + const res = spawnSync(npm.file, npm.args, { + cwd: rt, + stdio: ["ignore", logFd, logFd], + env: npm.env, + shell: npm.shell, + }); + return res.status === 0; + } finally { + fs.closeSync(logFd); + } } /* ------------------------------------------------------------------ server */ @@ -695,7 +728,10 @@ function bootstrap(rt = runtimeDir(), { force = false } = {}) { if (force || state.depsHash !== wantDeps || !fs.existsSync(path.join(rt, "node_modules"))) { log(rt, "installing runtime dependencies (first run takes a few minutes)"); if (!installDeps(rt)) { - log(rt, "npm install failed — see the output above; run /agenthub-doctor after fixing it"); + log( + rt, + `npm install failed — see ${path.join(rt, "npm-install.log")}; run /agenthub-doctor after fixing it` + ); return "install-failed"; } } @@ -713,13 +749,14 @@ function bootstrap(rt = runtimeDir(), { force = false } = {}) { // Built eagerly (not lazily behind /agenthub-open) so the dashboard, including // client-only routes like /run, works the instant `claude` is started — - // same trigger as the dependency install above: missing, or this plugin - // version has not built one yet. + // when missing, or when this plugin version has not built one yet + // (`clientBuild` is recorded only after a build that did not fail, so the + // next session retries a failed one instead of keeping the old UI for good). const { buildClient } = require("./plugin-open"); const uiResult = buildClient({ rt, root: PLUGIN_ROOT, - force: force || state.depsHash !== wantDeps, + force: force || state.clientBuild !== pluginVersion(), logPath: path.join(rt, "client-build.log"), }); if (uiResult === "failed") { @@ -829,6 +866,7 @@ function bootstrap(rt = runtimeDir(), { force = false } = {}) { { pluginVersion: pluginVersion(), depsHash: wantDeps, + clientBuild: uiResult === "failed" ? state.clientBuild : pluginVersion(), pluginRoot: PLUGIN_ROOT, claudeHome: getClaudeHome(), updatedAt: new Date().toISOString(), diff --git a/scripts/plugin-open.js b/scripts/plugin-open.js index 6c49e7bc..11cbecc6 100644 --- a/scripts/plugin-open.js +++ b/scripts/plugin-open.js @@ -68,7 +68,19 @@ function buildClient({ const stdio = logFd === null ? "inherit" : ["ignore", logFd, logFd]; try { const src = path.join(rt, "client-src"); - fs.rmSync(src, { recursive: true, force: true }); + // The installed tree is kept across updates while the client's manifests + // are unchanged (`stamp`, written after a successful install): a release + // rarely touches them, and the clean `npm ci` below fetched all of the + // ~200 MB toolchain again on every update. + const stamp = path.join(src, "node_modules", ".agenthub-deps"); + const want = clientDepsHash(root); + const reuse = readText(stamp) === want; + if (reuse) { + for (const e of fs.readdirSync(src)) + if (e !== "node_modules") fs.rmSync(path.join(src, e), { recursive: true, force: true }); + } else { + fs.rmSync(src, { recursive: true, force: true }); + } fs.cpSync(path.join(root, "client"), src, { recursive: true, filter: (p) => !/[\\/](node_modules|dist)$/.test(p), @@ -101,19 +113,25 @@ function buildClient({ // esbuild is the only dependency with an install script, and it finds its // platform binary without it. const npmFlags = ["--no-audit", "--no-fund", "--include=dev", "--ignore-scripts"]; - if ( - !exec(NPM, ["ci", ...npmFlags], src, stdio) && - !exec(NPM, ["install", ...npmFlags], src, stdio) - ) { - return "failed"; + if (!reuse) { + if ( + !exec(NPM, ["ci", ...npmFlags], src, stdio) && + !exec(NPM, ["install", ...npmFlags], src, stdio) + ) { + return "failed"; + } + fs.mkdirSync(path.dirname(stamp), { recursive: true }); + fs.writeFileSync(stamp, want); } - // = client/package.json's "build" script (`tsc -b && vite build`); a test - // keeps the two in step. + // client/package.json's "build" script is `tsc -b && vite build`; only the + // vite half runs here. The type check emits nothing the bundle needs, and on + // a user's machine it cost ~25 s and ~900 MB of memory per update — and a + // type error in a release then kept the old UI everywhere (v2.7.0). The + // release itself still runs `tsc -b`. A test keeps the two in step. const bin = (...p) => path.join(src, "node_modules", ...p); - if ( - !exec(NODE, [bin("typescript", "bin", "tsc"), "-b"], src, stdio) || - !exec(NODE, [bin("vite", "bin", "vite.js"), "build"], src, stdio) - ) { + if (!exec(NODE, [bin("vite", "bin", "vite.js"), "build"], src, stdio)) { + // Maybe a damaged kept tree: the next build starts from a clean install. + fs.rmSync(stamp, { force: true }); return "failed"; } @@ -125,6 +143,22 @@ function buildClient({ } } +function readText(file) { + try { + return fs.readFileSync(file, "utf8"); + } catch { + return null; + } +} + +/** The client's manifests, which decide whether its installed tree still fits. */ +function clientDepsHash(root) { + const h = require("crypto").createHash("sha256"); + for (const f of ["package.json", "package-lock.json"]) + h.update(readText(path.join(root, "client", f)) ?? `no ${f}`); + return h.digest("hex").slice(0, 16); +} + function dashboardUrl() { try { return `http://localhost:${resolveDashboardPort()}`; diff --git a/server/__tests__/plugin-bootstrap.test.js b/server/__tests__/plugin-bootstrap.test.js index 277eda7a..3a825fd3 100644 --- a/server/__tests__/plugin-bootstrap.test.js +++ b/server/__tests__/plugin-bootstrap.test.js @@ -91,6 +91,34 @@ describe("node version gate", () => { }); }); +describe("depsHash — only what npm installs", () => { + function root(pkg, lock) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-deps-")); + fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify(pkg)); + fs.writeFileSync(path.join(dir, "package-lock.json"), JSON.stringify(lock)); + return dir; + } + const pkg = (version, deps) => ({ name: "agenthub", version, dependencies: deps }); + const lock = (version, deps) => ({ + name: "agenthub", + version, + packages: { "": { name: "agenthub", version, dependencies: deps }, "node_modules/a": {} }, + }); + + it("ignores a release's version bump", () => { + // Every update reinstalled all runtime dependencies when only the version moved. + const a = root(pkg("1.0.0", { a: "^1" }), lock("1.0.0", { a: "^1" })); + const b = root(pkg("1.0.1", { a: "^1" }), lock("1.0.1", { a: "^1" })); + assert.equal(boot.depsHash(a), boot.depsHash(b)); + }); + + it("changes when a dependency does", () => { + const a = root(pkg("1.0.0", { a: "^1" }), lock("1.0.0", { a: "^1" })); + const b = root(pkg("1.0.0", { a: "^2" }), lock("1.0.0", { a: "^2" })); + assert.notEqual(boot.depsHash(a), boot.depsHash(b)); + }); +}); + describe("fast path", () => { beforeEach(resetRuntime); @@ -527,7 +555,6 @@ describe("buildClient dependency install", () => { assert.deepEqual(calls, [ "ci --no-audit --no-fund --include=dev --ignore-scripts", "install --no-audit --no-fund --include=dev --ignore-scripts", - "node tsc -b", "node vite.js build", ]); }); @@ -567,8 +594,9 @@ describe("buildClient dependency install", () => { it("builds with the running node, no lifecycle script and no PATH lookup (Windows)", () => { // On Windows a script or a .bin shim runs through cmd.exe and needs `node` // on PATH; the server's PATH lacked it, so esbuild's postinstall failed and - // every update kept the previous UI. The build here must equal the - // client's own "build" script, run without npm. + // every update kept the previous UI. The build here is the vite half of + // the client's own "build" script, run without npm; the type check is the + // release's job, not every user's. assert.equal( JSON.parse(fs.readFileSync(path.join(__dirname, "..", "..", "client", "package.json"))) .scripts.build, @@ -577,8 +605,35 @@ describe("buildClient dependency install", () => { resetRuntime(); const calls = []; buildClient({ rt: RT, root: fakeRoot(), exec: fakeExec(calls) }); - assert.deepEqual(calls.slice(-2), ["node tsc -b", "node vite.js build"]); - assert.ok(calls.every((c) => !c.startsWith("run"))); + assert.deepEqual(calls.slice(-1), ["node vite.js build"]); + assert.ok(calls.every((c) => !c.startsWith("run") && !c.includes("tsc"))); + }); + + it("keeps the installed tree while the client's manifests are unchanged", () => { + resetRuntime(); + const root = fakeRoot(); + const first = []; + buildClient({ rt: RT, root, exec: fakeExec(first) }); + assert.ok(first[0].startsWith("ci")); + const again = []; + assert.equal(buildClient({ rt: RT, root, force: true, exec: fakeExec(again) }), "built"); + assert.deepEqual(again, ["node vite.js build"]); + + fs.writeFileSync(path.join(root, "client", "package.json"), '{"dependencies":{"x":"1"}}'); + const changed = []; + buildClient({ rt: RT, root, force: true, exec: fakeExec(changed) }); + assert.ok(changed[0].startsWith("ci"), "a changed manifest installs from clean"); + }); + + it("installs from clean again after a build on the kept tree failed", () => { + resetRuntime(); + const root = fakeRoot(); + buildClient({ rt: RT, root, exec: fakeExec([]) }); + const failing = (cmd, args, cwd) => (cmd === "node" ? false : fakeExec([])(cmd, args, cwd)); + assert.equal(buildClient({ rt: RT, root, force: true, exec: failing }), "failed"); + const next = []; + buildClient({ rt: RT, root, force: true, exec: fakeExec(next) }); + assert.ok(next[0].startsWith("ci")); }); }); diff --git a/server/index.js b/server/index.js index 3fd4045b..7e503bbf 100644 --- a/server/index.js +++ b/server/index.js @@ -33,6 +33,20 @@ if (process.env.GIT_EDITOR === "true") delete process.env.GIT_EDITOR; // server/lib/settings-env.js. Only when this // file IS the process: the test suite requires it dozens of times, and a // developer's real settings file pins DASHBOARD_DATA_DIR at their live database. +/** What to tell the user when the dashboard cannot bind its port. The health + * probe above already turned away a port held by a running dashboard, so a + * port in use here belongs to some other program. */ +function listenFailure(err, port) { + if (err?.code === "EADDRINUSE") + return ( + `Port ${port} is already in use by another program, so AgentHub cannot start there. ` + + `Stop that program, or run AgentHub on another port with DASHBOARD_PORT (for example DASHBOARD_PORT=4821).` + ); + if (err?.code === "EACCES") + return `Not allowed to listen on port ${port}. Pick a port above 1024 with DASHBOARD_PORT.`; + return `AgentHub could not start its server: ${err?.stack || err}`; +} + if (require.main === module) { const settingsEnv = require("./lib/settings-env"); settingsEnv.loadEnvFiles({ @@ -337,8 +351,12 @@ function startServer(app, port) { const host = resolveHost(); const boundLoopback = isLoopbackHostname(host); - return new Promise((resolve) => { + return new Promise((resolve, reject) => { + // A port another program holds fails here, not as an uncaught 'error' + // event: the caller says what happened instead of a bare stack trace. + server.once("error", reject); server.listen(port, host, () => { + server.off("error", reject); // Publish the live port so the Claude Code hook handler can find this // server even when it bound a non-default port (the desktop app falls // back off 4820 when that port is already taken). @@ -1210,12 +1228,17 @@ if (require.main === module) { return; } const app = createApp(); - startServer(app, PORT).then((server) => { - httpServer = server; - startBackgroundServices(); - // The status-area icon (server/lib/tray.js) — production servers only. - require("./lib/tray").startTrayWatch(); - }); + startServer(app, PORT) + .then((server) => { + httpServer = server; + startBackgroundServices(); + // The status-area icon (server/lib/tray.js) — production servers only. + require("./lib/tray").startTrayWatch(); + }) + .catch((err) => { + console.error(listenFailure(err, PORT)); + process.exit(1); + }); }); // Graceful shutdown — close connections and DB cleanly