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).
This commit is contained in:
2026-10-16 10:05:03 +07:00
parent a58e95a957
commit ecbbb4dd8f
17 changed files with 313 additions and 89 deletions
+7 -9
View File
@@ -401,18 +401,16 @@ This was a known timezone bug (fixed in current version). If you are still seein
### Port 4820 already in use ### 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 ```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`: 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).
```ts
proxy: {
"/api": "http://localhost:4821",
"/ws": { target: "ws://localhost:4821", ws: true }
}
```
And make sure Claude Code posts hooks to the new port: And make sure Claude Code posts hooks to the new port:
+13 -2
View File
@@ -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 { Outlet, useLocation } from "react-router-dom";
import { Menu } from "lucide-react"; import { Menu } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -85,9 +85,15 @@ import { UpdateNotifier } from "./UpdateNotifier";
import { InterruptedSessionsPrompt } from "./lanes/InterruptedSessionsPrompt"; import { InterruptedSessionsPrompt } from "./lanes/InterruptedSessionsPrompt";
import { SagiMount } from "./Sagi/SagiMount"; import { SagiMount } from "./Sagi/SagiMount";
import { StatusBar } from "./StatusBar"; import { StatusBar } from "./StatusBar";
import { OnboardingWizard } from "./onboarding/OnboardingWizard"; import { useOnboarding } from "../lib/userPrefs";
import { OnboardingTour } from "./onboarding/OnboardingTour"; 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"; const WIDTH_KEY = "agenthub.sidebar.width";
/** The default width, and the narrowest the edge drags to. */ /** The default width, and the narrowest the edge drags to. */
export const SIDEBAR_MIN = 240; export const SIDEBAR_MIN = 240;
@@ -136,6 +142,7 @@ export function Layout({ wsConnected }: LayoutProps) {
const { t } = useTranslation("nav"); const { t } = useTranslation("nav");
const [collapsed, setCollapsed] = useState(loadCollapsed); const [collapsed, setCollapsed] = useState(loadCollapsed);
const [connectionOpen, setConnectionOpen] = useState(false); const [connectionOpen, setConnectionOpen] = useState(false);
const onboarding = useOnboarding();
const [updateAvailable, setUpdateAvailable] = useState(false); const [updateAvailable, setUpdateAvailable] = useState(false);
const drawer = useMedia(DRAWER_QUERY); const drawer = useMedia(DRAWER_QUERY);
const [drawerOpen, setDrawerOpen] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false);
@@ -177,7 +184,11 @@ export function Layout({ wsConnected }: LayoutProps) {
<UpdateNotifier /> <UpdateNotifier />
<InterruptedSessionsPrompt /> <InterruptedSessionsPrompt />
<SagiMount /> <SagiMount />
{onboarding.loaded && !onboarding.onboardedAt && (
<Suspense fallback={null}>
<OnboardingWizard /> <OnboardingWizard />
</Suspense>
)}
<OnboardingTour /> <OnboardingTour />
{drawer && drawerOpen && ( {drawer && drawerOpen && (
<div <div
+1 -17
View File
@@ -78,6 +78,7 @@ import {
} from "react"; } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useSettingsHref } from "../../lib/settingsLink"; import { useSettingsHref } from "../../lib/settingsLink";
import { usePrefersReducedMotion } from "../../hooks/usePrefersReducedMotion";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { SagiAvatar } from "./SagiAvatar"; import { SagiAvatar } from "./SagiAvatar";
import { PetSprite } from "./PetSprite"; import { PetSprite } from "./PetSprite";
@@ -126,23 +127,6 @@ interface Anchor {
dragging: boolean; dragging: boolean;
} }
/** 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;
}
/** /**
* Fixed-position wrapper that places its content next to the avatar and clamps * 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 * it inside the viewport. It measures itself (and re-measures on content/size
+11 -4
View File
@@ -59,7 +59,13 @@ import { useTranslation } from "react-i18next";
import { ArrowUp, FileText, Square, SquareCheck } from "lucide-react"; import { ArrowUp, FileText, Square, SquareCheck } from "lucide-react";
import { isSubmitEnter } from "../../lib/keys"; import { isSubmitEnter } from "../../lib/keys";
import { ConversationView } from "../conversation/ConversationView"; 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 { ChatAddMenu } from "./ChatAddMenu";
import { addAttachments, dragAttachments } from "../../lib/promptAttach"; import { addAttachments, dragAttachments } from "../../lib/promptAttach";
import { readChatDraft, writeChatDraft } from "../../lib/chatDraft"; import { readChatDraft, writeChatDraft } from "../../lib/chatDraft";
@@ -491,7 +497,7 @@ export const PaneChat = memo(function PaneChat({
const [notStarted, setNotStarted] = useState(false); const [notStarted, setNotStarted] = useState(false);
// A control is a command typed into the pane, like the user would. // 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 ( if (
notStarted && notStarted &&
!(await confirmDialog({ !(await confirmDialog({
@@ -501,14 +507,15 @@ export const PaneChat = memo(function PaneChat({
})) }))
) )
return false; return false;
const ok = submitToRun(runId, line); const ok = submitToRun(runId, line, files);
if (!ok) toast.error(t("chat.notSent")); if (!ok) toast.error(t("chat.notSent"));
return ok; return ok;
}; };
const send = async () => { const send = async () => {
if (!text || uploading) return; 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); setFollow((n) => n + 1);
setPrompt(""); setPrompt("");
images.forEach((i) => i.url && URL.revokeObjectURL(i.url)); images.forEach((i) => i.url && URL.revokeObjectURL(i.url));
+3 -3
View File
@@ -73,7 +73,7 @@ import { RunDot, type RunState } from "./ConsoleTabs";
import { setSessionChat, useSessionChat } from "../../lib/chatUi"; import { setSessionChat, useSessionChat } from "../../lib/chatUi";
import { readChatDraft, writeChatDraft } from "../../lib/chatDraft"; import { readChatDraft, writeChatDraft } from "../../lib/chatDraft";
import { pasteToRun, takeRunInput } from "../../lib/panePaste"; import { pasteToRun, takeRunInput } from "../../lib/panePaste";
import { promptWithImages } from "./RunSetup"; import { promptForPane } from "./RunSetup";
export const PANE_DRAG_TYPE = "application/x-agenthub-pane"; export const PANE_DRAG_TYPE = "application/x-agenthub-pane";
/** The slot being dragged: `dataTransfer` data cannot be read during dragover. */ /** The slot being dragged: `dataTransfer` data cannot be read during dragover. */
@@ -246,8 +246,8 @@ const ITEM =
* the CLI's input box, without Enter. */ * the CLI's input box, without Enter. */
export function showTerminal(runId: string, sessionId: string) { export function showTerminal(runId: string, sessionId: string) {
const draft = readChatDraft(sessionId); const draft = readChatDraft(sessionId);
const text = promptWithImages(draft.prompt, draft.images); const { text, files } = promptForPane(draft.prompt, draft.images);
if (text && pasteToRun(runId, text)) writeChatDraft(sessionId, "", []); if ((text || files.length) && pasteToRun(runId, text, files)) writeChatDraft(sessionId, "", []);
setSessionChat(sessionId, false); setSessionChat(sessionId, false);
} }
+17
View File
@@ -208,6 +208,23 @@ export function promptWithImages(text: string, images: PromptImage[]): string {
return [text.trim(), ...images.map((i) => i.path)].filter(Boolean).join(" "); 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`. */ /** A plain-text paste longer than this is attached as `pasted-text.txt`. */
export const PASTE_AS_FILE_CHARS = 5000; export const PASTE_AS_FILE_CHARS = 5000;
+6 -2
View File
@@ -666,8 +666,12 @@ export const TerminalView = memo(function TerminalView({
() => () =>
registerPane( registerPane(
runId, runId,
(text, submit) => { (text, submit, files = []) => {
termRef.current?.paste(text); 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 // 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 // detection never reads it as a newline inside the paste. A submit
// comes from the pane's Chat view, which keeps the keyboard. // comes from the pane's Chat view, which keeps the keyboard.
@@ -267,7 +267,7 @@ describe("PaneChat — model, effort and permission mode", () => {
renderChat(); renderChat();
fireEvent.click(screen.getByRole("button", { name: /Opus/ })); fireEvent.click(screen.getByRole("button", { name: /Opus/ }));
fireEvent.click(await screen.findByRole("menuitemradio", { name: /Sonnet/ })); 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 () => { 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.getByPlaceholderText("Write a message…")).toHaveValue("half typed");
expect(screen.getByText("a.ts")).toBeInTheDocument(); expect(screen.getByText("a.ts")).toBeInTheDocument();
fireEvent.click(screen.getByTestId("pane-chat-send")); 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()); 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")); fireEvent.click(screen.getByTestId("pane-chat-send"));
expect(submitToRun).toHaveBeenCalledWith( expect(submitToRun).toHaveBeenCalledWith(
"agenthub-lane-1", "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"); expect(box).toHaveValue("hello");
confirmDialog.mockResolvedValueOnce(true); confirmDialog.mockResolvedValueOnce(true);
fireEvent.click(screen.getByTestId("pane-chat-send")); 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 () => { it("sends without asking once the session has started", async () => {
@@ -499,7 +500,7 @@ describe("PaneChat — the unsent prompt", () => {
renderChat(); renderChat();
fireEvent.change(screen.getByPlaceholderText("Write a message…"), { target: { value: "hi" } }); fireEvent.change(screen.getByPlaceholderText("Write a message…"), { target: { value: "hi" } });
fireEvent.click(screen.getByTestId("pane-chat-send")); 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(); expect(confirmDialog).not.toHaveBeenCalled();
}); });
}); });
@@ -24,6 +24,7 @@ import {
ModelPicker, ModelPicker,
PASTE_AS_FILE_CHARS, PASTE_AS_FILE_CHARS,
PromptEditor, PromptEditor,
promptForPane,
promptWithImages, promptWithImages,
type PromptImage, type PromptImage,
} from "../RunSetup"; } from "../RunSetup";
@@ -586,3 +587,23 @@ describe("ModelMenu", () => {
expect(onChange).toHaveBeenCalledWith("claude-sonnet-5-5"); 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",
],
});
});
});
@@ -28,7 +28,7 @@ import { SettingsRow, SettingsSwitchRow } from "./SettingsRow";
import { SagiAvatar } from "../Sagi/SagiAvatar"; import { SagiAvatar } from "../Sagi/SagiAvatar";
import { PetSprite } from "../Sagi/PetSprite"; import { PetSprite } from "../Sagi/PetSprite";
import { PET_SIZE, sagiPrefs } from "../Sagi/prefs"; import { PET_SIZE, sagiPrefs } from "../Sagi/prefs";
import { usePrefersReducedMotion } from "../Sagi/Sagi"; import { usePrefersReducedMotion } from "../../hooks/usePrefersReducedMotion";
import { confirmDialog } from "../../lib/confirm"; import { confirmDialog } from "../../lib/confirm";
import { toast } from "../../lib/toast"; import { toast } from "../../lib/toast";
import { usePets, useWornPet } from "../Sagi/usePets"; import { usePets, useWornPet } from "../Sagi/usePets";
@@ -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ĩ <vinnt@smartgift.vn>
*/
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;
}
+9 -5
View File
@@ -14,6 +14,9 @@
* terminal moves the unsent prompt across (`takeRunInput` reads the TUI's * 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 * 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. * 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 * The Chat view also answers a menu the CLI asks with (`parsePaneMenu` reads
* it off the screen; `chooseRunOption`/`answerRunMenu` press its digit, the * it off the screen; `chooseRunOption`/`answerRunMenu` press its digit, the
* arrows and Enter, or type into its "Type something" row). * arrows and Enter, or type into its "Type something" row).
@@ -24,7 +27,8 @@
export type PaneScreen = { rows: string[]; cols: number }; export type PaneScreen = { rows: string[]; cols: number };
interface Pane { 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; visible: () => boolean;
key?: (data: string) => void; key?: (data: string) => void;
/** `menuOnly`: null at once unless a row could be a menu's cursor row. */ /** `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; /** Pastes `text` into the pane of `runId` when it is mounted and on screen;
* false otherwise (not mounted yet, or hidden). */ * 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); const p = panes.get(runId);
if (!p?.visible()) return false; if (!p?.visible()) return false;
p.paste(text); p.paste(text, false, files);
return true; return true;
} }
/** Sends `text` as a message to the pane of `runId`: a paste, then Enter. /** Sends `text` as a message to the pane of `runId`: a paste, then Enter.
* False when the pane is not mounted or not attached. */ * 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); const p = panes.get(runId);
if (!p?.visible()) return false; if (!p?.visible()) return false;
p.paste(text, true); p.paste(text, true, files);
return true; return true;
} }
+4 -4
View File
@@ -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)). 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). 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. 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. 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). 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 9. **Server** — when the user has turned **Start AgentHub automatically** on
(Settings → System; OFF by default), install/refresh the (Settings → System; OFF by default), install/refresh the
`agenthub.service` unit on Linux — on macOS with a GUI login, 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 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) makes client-only routes (`http://localhost:4820/run`, and every other page)
work the moment `claude` starts, with no manual `/agenthub-open` step. Progress work the moment `claude` starts, with no manual `/agenthub-open` step. Progress
goes to `~/.claude/agenthub/runtime/bootstrap.log`; the UI build's own goes to `~/.claude/agenthub/runtime/bootstrap.log`; npm's own output goes to
output goes to `client-build.log` next to it; the server'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`. `server.log`.
### A retired build never acts ### A retired build never acts
+44 -6
View File
@@ -138,14 +138,38 @@ function serverIsLive() {
return livePids().length > 0; 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. */ /** Hash of the dependency manifest — changes mean the runtime tree must be reinstalled. */
function depsHash(root = PLUGIN_ROOT) { function depsHash(root = PLUGIN_ROOT) {
const h = crypto.createHash("sha256"); const h = crypto.createHash("sha256");
for (const f of ["package.json", "package-lock.json"]) { for (const f of ["package.json", "package-lock.json"]) {
let text;
try { try {
h.update(fs.readFileSync(path.join(root, f))); text = fs.readFileSync(path.join(root, f), "utf8");
} catch { } catch {
h.update(f); // absent counts as its own state 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); 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)); if (fs.existsSync(src)) fs.copyFileSync(src, path.join(rt, f));
} }
const npm = npmCommand(["install", "--omit=dev", "--ignore-scripts", "--no-audit", "--no-fund"]); const npm = npmCommand(["install", "--omit=dev", "--ignore-scripts", "--no-audit", "--no-fund"]);
// 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, { const res = spawnSync(npm.file, npm.args, {
cwd: rt, cwd: rt,
stdio: "inherit", stdio: ["ignore", logFd, logFd],
env: npm.env, env: npm.env,
shell: npm.shell, shell: npm.shell,
}); });
return res.status === 0; return res.status === 0;
} finally {
fs.closeSync(logFd);
}
} }
/* ------------------------------------------------------------------ server */ /* ------------------------------------------------------------------ server */
@@ -695,7 +728,10 @@ function bootstrap(rt = runtimeDir(), { force = false } = {}) {
if (force || state.depsHash !== wantDeps || !fs.existsSync(path.join(rt, "node_modules"))) { if (force || state.depsHash !== wantDeps || !fs.existsSync(path.join(rt, "node_modules"))) {
log(rt, "installing runtime dependencies (first run takes a few minutes)"); log(rt, "installing runtime dependencies (first run takes a few minutes)");
if (!installDeps(rt)) { 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"; return "install-failed";
} }
} }
@@ -713,13 +749,14 @@ function bootstrap(rt = runtimeDir(), { force = false } = {}) {
// Built eagerly (not lazily behind /agenthub-open) so the dashboard, including // Built eagerly (not lazily behind /agenthub-open) so the dashboard, including
// client-only routes like /run, works the instant `claude` is started — // client-only routes like /run, works the instant `claude` is started —
// same trigger as the dependency install above: missing, or this plugin // when missing, or when this plugin version has not built one yet
// 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 { buildClient } = require("./plugin-open");
const uiResult = buildClient({ const uiResult = buildClient({
rt, rt,
root: PLUGIN_ROOT, root: PLUGIN_ROOT,
force: force || state.depsHash !== wantDeps, force: force || state.clientBuild !== pluginVersion(),
logPath: path.join(rt, "client-build.log"), logPath: path.join(rt, "client-build.log"),
}); });
if (uiResult === "failed") { if (uiResult === "failed") {
@@ -829,6 +866,7 @@ function bootstrap(rt = runtimeDir(), { force = false } = {}) {
{ {
pluginVersion: pluginVersion(), pluginVersion: pluginVersion(),
depsHash: wantDeps, depsHash: wantDeps,
clientBuild: uiResult === "failed" ? state.clientBuild : pluginVersion(),
pluginRoot: PLUGIN_ROOT, pluginRoot: PLUGIN_ROOT,
claudeHome: getClaudeHome(), claudeHome: getClaudeHome(),
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
+40 -6
View File
@@ -68,7 +68,19 @@ function buildClient({
const stdio = logFd === null ? "inherit" : ["ignore", logFd, logFd]; const stdio = logFd === null ? "inherit" : ["ignore", logFd, logFd];
try { try {
const src = path.join(rt, "client-src"); const src = path.join(rt, "client-src");
// 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.rmSync(src, { recursive: true, force: true });
}
fs.cpSync(path.join(root, "client"), src, { fs.cpSync(path.join(root, "client"), src, {
recursive: true, recursive: true,
filter: (p) => !/[\\/](node_modules|dist)$/.test(p), 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 // esbuild is the only dependency with an install script, and it finds its
// platform binary without it. // platform binary without it.
const npmFlags = ["--no-audit", "--no-fund", "--include=dev", "--ignore-scripts"]; const npmFlags = ["--no-audit", "--no-fund", "--include=dev", "--ignore-scripts"];
if (!reuse) {
if ( if (
!exec(NPM, ["ci", ...npmFlags], src, stdio) && !exec(NPM, ["ci", ...npmFlags], src, stdio) &&
!exec(NPM, ["install", ...npmFlags], src, stdio) !exec(NPM, ["install", ...npmFlags], src, stdio)
) { ) {
return "failed"; return "failed";
} }
// = client/package.json's "build" script (`tsc -b && vite build`); a test fs.mkdirSync(path.dirname(stamp), { recursive: true });
// keeps the two in step. fs.writeFileSync(stamp, want);
}
// 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); const bin = (...p) => path.join(src, "node_modules", ...p);
if ( if (!exec(NODE, [bin("vite", "bin", "vite.js"), "build"], src, stdio)) {
!exec(NODE, [bin("typescript", "bin", "tsc"), "-b"], src, stdio) || // Maybe a damaged kept tree: the next build starts from a clean install.
!exec(NODE, [bin("vite", "bin", "vite.js"), "build"], src, stdio) fs.rmSync(stamp, { force: true });
) {
return "failed"; 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() { function dashboardUrl() {
try { try {
return `http://localhost:${resolveDashboardPort()}`; return `http://localhost:${resolveDashboardPort()}`;
+60 -5
View File
@@ -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", () => { describe("fast path", () => {
beforeEach(resetRuntime); beforeEach(resetRuntime);
@@ -527,7 +555,6 @@ describe("buildClient dependency install", () => {
assert.deepEqual(calls, [ assert.deepEqual(calls, [
"ci --no-audit --no-fund --include=dev --ignore-scripts", "ci --no-audit --no-fund --include=dev --ignore-scripts",
"install --no-audit --no-fund --include=dev --ignore-scripts", "install --no-audit --no-fund --include=dev --ignore-scripts",
"node tsc -b",
"node vite.js build", "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)", () => { 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 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 // 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 // every update kept the previous UI. The build here is the vite half of
// client's own "build" script, run without npm. // the client's own "build" script, run without npm; the type check is the
// release's job, not every user's.
assert.equal( assert.equal(
JSON.parse(fs.readFileSync(path.join(__dirname, "..", "..", "client", "package.json"))) JSON.parse(fs.readFileSync(path.join(__dirname, "..", "..", "client", "package.json")))
.scripts.build, .scripts.build,
@@ -577,8 +605,35 @@ describe("buildClient dependency install", () => {
resetRuntime(); resetRuntime();
const calls = []; const calls = [];
buildClient({ rt: RT, root: fakeRoot(), exec: fakeExec(calls) }); buildClient({ rt: RT, root: fakeRoot(), exec: fakeExec(calls) });
assert.deepEqual(calls.slice(-2), ["node tsc -b", "node vite.js build"]); assert.deepEqual(calls.slice(-1), ["node vite.js build"]);
assert.ok(calls.every((c) => !c.startsWith("run"))); 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"));
}); });
}); });
+25 -2
View File
@@ -33,6 +33,20 @@ if (process.env.GIT_EDITOR === "true") delete process.env.GIT_EDITOR;
// server/lib/settings-env.js. Only when this // server/lib/settings-env.js. Only when this
// file IS the process: the test suite requires it dozens of times, and a // 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. // 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) { if (require.main === module) {
const settingsEnv = require("./lib/settings-env"); const settingsEnv = require("./lib/settings-env");
settingsEnv.loadEnvFiles({ settingsEnv.loadEnvFiles({
@@ -337,8 +351,12 @@ function startServer(app, port) {
const host = resolveHost(); const host = resolveHost();
const boundLoopback = isLoopbackHostname(host); 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.listen(port, host, () => {
server.off("error", reject);
// Publish the live port so the Claude Code hook handler can find this // 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 // server even when it bound a non-default port (the desktop app falls
// back off 4820 when that port is already taken). // back off 4820 when that port is already taken).
@@ -1210,11 +1228,16 @@ if (require.main === module) {
return; return;
} }
const app = createApp(); const app = createApp();
startServer(app, PORT).then((server) => { startServer(app, PORT)
.then((server) => {
httpServer = server; httpServer = server;
startBackgroundServices(); startBackgroundServices();
// The status-area icon (server/lib/tray.js) — production servers only. // The status-area icon (server/lib/tray.js) — production servers only.
require("./lib/tray").startTrayWatch(); require("./lib/tray").startTrayWatch();
})
.catch((err) => {
console.error(listenFailure(err, PORT));
process.exit(1);
}); });
}); });