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:
+50
-12
@@ -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(),
|
||||
|
||||
+46
-12
@@ -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()}`;
|
||||
|
||||
Reference in New Issue
Block a user