Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license

This commit is contained in:
2026-09-05 13:26:46 +03:00
commit 03634b1ca3
11340 changed files with 3442369 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
share-codes.txt
+41
View File
@@ -0,0 +1,41 @@
/**
* after-pack.mjs — electron-builder afterPack hook.
*
* Stamps the Hermes icon + identity onto the packed Windows Hermes.exe via
* rcedit (delegated to set-exe-identity.mjs). This runs for EVERY packed build
* — first install, `hermes desktop`, the installer's --update rebuild, and a
* dev's manual `npm run pack` — so the branded exe can never silently revert
* to the stock "Electron" icon/name (the bug when the stamp lived only in
* install.ps1, which the update path doesn't use).
*
* Windows-only: rcedit edits PE resources, irrelevant on macOS/Linux where the
* app identity comes from the bundle Info.plist / desktop entry. AITURK builds
* fail if executable branding cannot be applied.
*
* electron-builder passes a context with:
* - electronPlatformName: 'win32' | 'darwin' | 'linux'
* - appOutDir: the unpacked app directory for this target
* - packager.appInfo.productFilename: the exe basename (e.g. 'Hermes')
*/
import path from 'node:path'
import { existsSync } from 'node:fs'
import { stampExeIdentity } from './set-exe-identity.mjs'
export default async function afterPack(context) {
if (context.electronPlatformName !== 'win32') {
return
}
const productName = context.packager?.appInfo?.productFilename || 'Hermes'
const configuredExe = path.join(context.appOutDir, 'AITURK-IDE.exe')
const exe = existsSync(configuredExe) ? configuredExe : path.join(context.appOutDir, `${productName}.exe`)
const desktopRoot = path.resolve(import.meta.dirname, '..')
try {
await stampExeIdentity(exe, desktopRoot)
} catch (err) {
throw new Error(`AITURK executable branding failed: ${err.message}`)
}
}
+105
View File
@@ -0,0 +1,105 @@
// Build-time guard: refuse to hand a half-built renderer to electron-builder.
//
// `npm run pack` / `npm run dist*` are `npm run build && npm run builder`.
// If the `build` step (tsc -b && vite build) fails but packaging proceeds
// anyway — a stale checkout that fails typecheck, an interrupted vite build,
// or npm not short-circuiting `&&` in some shells — electron-builder happily
// packages an app with an empty or missing `dist/`. The result launches but
// blank-pages with `ERR_FILE_NOT_FOUND` for dist/index.html, with no clue why.
//
// This runs at the tail of `build`, after vite build, so any packaging path
// inherits it. It fails loud and early instead of shipping a broken bundle.
// See issues #39484 (renderer blank page) and #41327 / #39472 (dashboard 404).
import { existsSync, readFileSync, statSync, readdirSync } from "fs"
import { join, resolve } from "path"
import { isMain } from "./utils.mjs"
const ROUTER_CONTEXT_ERROR = "may be used only in the context of a"
// @tanstack/react-query carries module-level React context (QueryClientContext).
// The entry's QueryClientProvider and every lazy chunk's useQuery must share ONE
// runtime instance; if a build ever emits a second copy, the provider's context
// is invisible to the other copy and useQuery throws "No QueryClient set" — the
// packaged app error-boundaries on launch (#95560). Same single-instance
// invariant as the react-router check above, same failure class.
const QUERY_CLIENT_CONTEXT_ERROR = "No QueryClient set, use QueryClientProvider to set one"
// Pure check — returns { ok: true } or { ok: false, error: "..." }.
// Kept side-effect-free so it can be unit tested without spawning a process.
export function checkDistBuilt(distDir) {
if (!existsSync(distDir) || !statSync(distDir).isDirectory()) {
return { ok: false, error: `no dist directory at ${distDir}` }
}
const indexHtml = join(distDir, "index.html")
if (!existsSync(indexHtml) || !statSync(indexHtml).isFile()) {
return { ok: false, error: `dist/index.html is missing at ${indexHtml}` }
}
if (statSync(indexHtml).size === 0) {
return { ok: false, error: `dist/index.html is empty at ${indexHtml}` }
}
// index.html alone isn't enough — vite emits hashed JS into dist/assets.
// An index.html with no script bundle still blank-pages.
const assetsDir = join(distDir, "assets")
const hasAssets =
existsSync(assetsDir) &&
statSync(assetsDir).isDirectory() &&
readdirSync(assetsDir).some(name => name.endsWith(".js"))
if (!hasAssets) {
return { ok: false, error: `dist/assets has no built JS bundle (expected vite output under ${assetsDir})` }
}
const routerContextAssets = readdirSync(assetsDir)
.filter(name => name.endsWith(".js"))
.filter(name => readFileSync(join(assetsDir, name), "utf8").includes(ROUTER_CONTEXT_ERROR))
if (routerContextAssets.length > 1) {
return {
ok: false,
error: `react-router context invariant found in multiple JS assets: ${routerContextAssets.join(", ")}`
}
}
const queryClientContextAssets = readdirSync(assetsDir)
.filter(name => name.endsWith(".js"))
.filter(name => readFileSync(join(assetsDir, name), "utf8").includes(QUERY_CLIENT_CONTEXT_ERROR))
if (queryClientContextAssets.length > 1) {
return {
ok: false,
error:
`@tanstack/react-query context invariant found in multiple JS assets: ` +
`${queryClientContextAssets.join(", ")} — duplicate react-query runtimes make the ` +
`QueryClientProvider's context invisible to useQuery in other chunks (` +
`"No QueryClient set" on launch, #95560)`
}
}
return { ok: true }
}
function main() {
const desktopRoot = resolve(import.meta.dirname, "..")
const distDir = join(desktopRoot, "dist")
const result = checkDistBuilt(distDir)
if (!result.ok) {
console.error(`\n✗ assert-dist-built: ${result.error}`)
console.error(" The renderer bundle is missing or incomplete, so packaging")
console.error(" would produce an app that launches to a blank page.")
console.error(" Re-run the build and check the tsc/vite output above for the")
console.error(" real failure, then package again:")
console.error(` cd ${desktopRoot} && npm run build\n`)
process.exit(1)
}
console.log("✓ assert-dist-built: dist/index.html + assets present")
}
if (isMain(import.meta.url)) {
main()
}
export default { checkDistBuilt }
@@ -0,0 +1,164 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { test } from 'vitest'
import { checkDistBuilt } from '../scripts/assert-dist-built.mjs'
function makeDist(extra) {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-assert-dist-'))
const distDir = path.join(tempRoot, 'dist')
fs.mkdirSync(distDir, { recursive: true })
if (extra) extra(distDir)
return { tempRoot, distDir }
}
function writeRouterAsset(distDir, name) {
fs.writeFileSync(
path.join(distDir, 'assets', name),
`throw new Error('may be used only in the context of a <Router>')`,
'utf8',
)
}
function writeQueryClientAsset(distDir, name) {
fs.writeFileSync(
path.join(distDir, 'assets', name),
`throw new Error('No QueryClient set, use QueryClientProvider to set one')`,
'utf8',
)
}
test('checkDistBuilt passes when index.html + an assets JS bundle exist', () => {
const { tempRoot, distDir } = makeDist(d => {
fs.writeFileSync(path.join(d, 'index.html'), '<!doctype html><div id=root></div>', 'utf8')
fs.mkdirSync(path.join(d, 'assets'))
fs.writeFileSync(path.join(d, 'assets', 'index-abc123.js'), 'console.log(1)', 'utf8')
})
try {
assert.deepEqual(checkDistBuilt(distDir), { ok: true })
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true })
}
})
test('checkDistBuilt fails when the dist directory is absent', () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-assert-dist-'))
try {
const result = checkDistBuilt(path.join(tempRoot, 'dist'))
assert.equal(result.ok, false)
assert.match(result.error, /no dist directory/)
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true })
}
})
test('checkDistBuilt fails when index.html is missing', () => {
const { tempRoot, distDir } = makeDist(d => {
fs.mkdirSync(path.join(d, 'assets'))
fs.writeFileSync(path.join(d, 'assets', 'index-abc123.js'), 'console.log(1)', 'utf8')
})
try {
const result = checkDistBuilt(distDir)
assert.equal(result.ok, false)
assert.match(result.error, /index\.html is missing/)
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true })
}
})
test('checkDistBuilt fails when index.html is empty', () => {
const { tempRoot, distDir } = makeDist(d => {
fs.writeFileSync(path.join(d, 'index.html'), '', 'utf8')
fs.mkdirSync(path.join(d, 'assets'))
fs.writeFileSync(path.join(d, 'assets', 'index-abc123.js'), 'console.log(1)', 'utf8')
})
try {
const result = checkDistBuilt(distDir)
assert.equal(result.ok, false)
assert.match(result.error, /index\.html is empty/)
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true })
}
})
test('checkDistBuilt fails when assets/ has no JS bundle', () => {
const { tempRoot, distDir } = makeDist(d => {
fs.writeFileSync(path.join(d, 'index.html'), '<!doctype html>', 'utf8')
fs.mkdirSync(path.join(d, 'assets'))
// CSS only, no JS — still a blank page at runtime.
fs.writeFileSync(path.join(d, 'assets', 'index-abc123.css'), 'body{}', 'utf8')
})
try {
const result = checkDistBuilt(distDir)
assert.equal(result.ok, false)
assert.match(result.error, /no built JS bundle/)
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true })
}
})
test('checkDistBuilt passes when the Router context invariant is in one JS asset', () => {
const { tempRoot, distDir } = makeDist(d => {
fs.writeFileSync(path.join(d, 'index.html'), '<!doctype html>', 'utf8')
fs.mkdirSync(path.join(d, 'assets'))
writeRouterAsset(d, 'vendor-react-abc123.js')
fs.writeFileSync(path.join(d, 'assets', 'command-def456.js'), 'console.log(1)', 'utf8')
})
try {
assert.deepEqual(checkDistBuilt(distDir), { ok: true })
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true })
}
})
test('checkDistBuilt fails when the Router context invariant is in multiple JS assets', () => {
const { tempRoot, distDir } = makeDist(d => {
fs.writeFileSync(path.join(d, 'index.html'), '<!doctype html>', 'utf8')
fs.mkdirSync(path.join(d, 'assets'))
writeRouterAsset(d, 'vendor-react-abc123.js')
writeRouterAsset(d, 'command-def456.js')
})
try {
const result = checkDistBuilt(distDir)
assert.equal(result.ok, false)
assert.match(result.error, /react-router context invariant found in multiple JS assets/)
assert.match(result.error, /vendor-react-abc123\.js/)
assert.match(result.error, /command-def456\.js/)
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true })
}
})
test('checkDistBuilt passes when the QueryClient context invariant is in one JS asset', () => {
const { tempRoot, distDir } = makeDist(d => {
fs.writeFileSync(path.join(d, 'index.html'), '<!doctype html>', 'utf8')
fs.mkdirSync(path.join(d, 'assets'))
writeQueryClientAsset(d, 'vendor-react-abc123.js')
fs.writeFileSync(path.join(d, 'assets', 'command-def456.js'), 'console.log(1)', 'utf8')
})
try {
assert.deepEqual(checkDistBuilt(distDir), { ok: true })
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true })
}
})
test('checkDistBuilt fails when the QueryClient context invariant is in multiple JS assets (#95560)', () => {
const { tempRoot, distDir } = makeDist(d => {
fs.writeFileSync(path.join(d, 'index.html'), '<!doctype html>', 'utf8')
fs.mkdirSync(path.join(d, 'assets'))
writeQueryClientAsset(d, 'vendor-react-abc123.js')
writeQueryClientAsset(d, 'session-list-density-def456.js')
})
try {
const result = checkDistBuilt(distDir)
assert.equal(result.ok, false)
assert.match(result.error, /@tanstack\/react-query context invariant found in multiple JS assets/)
assert.match(result.error, /vendor-react-abc123\.js/)
assert.match(result.error, /session-list-density-def456\.js/)
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true })
}
})
@@ -0,0 +1,146 @@
// Build-time guard: refuse to start a build the installed tree cannot finish.
//
// The desktop workspace's dependencies are hoisted to the repo-root
// `node_modules`, so a root install that only covers *part* of the workspace
// graph leaves this app importable-looking but unbuildable. The guard exists to
// turn that into one actionable line ("run npm ci from the repo root") instead
// of a failure deep inside vite.
//
// It runs from `prebuild`, ahead of `npm run clean`, so a tree that cannot
// build is rejected before the build starts deleting its own outputs. `build`
// re-runs it for anyone invoking the build steps directly; the check is pure
// filesystem lookups, so paying for it twice costs nothing.
import { existsSync, readFileSync } from "fs"
import { createRequire } from "module"
import { resolve, join, dirname } from "path"
import { isMain } from "./utils.mjs"
// Packages the build *consumes*, as opposed to merely declares. Each one is
// load-bearing for a distinct build step, and each one has been observed
// missing from a partial root install:
//
// vite — bundles the renderer (`vite build`).
// katex — `src/styles.css` imports `katex/dist/katex.min.css`, so
// the CSS transform fails before a single chunk is emitted.
// electron — the runtime electron-builder packages; without it `pack`
// cannot produce an unpacked app at all.
// electron-builder — the packager `npm run builder` shells out to.
//
// Checking only `vite` (the original guard) passes a tree missing any of the
// others, which is how an incomplete install reached `vite build` and died on
// an unresolved `katex/dist/katex.min.css` with no hint that the install — not
// the source — was at fault (#86443).
//
// These four are the documented floor — always checked, even when the app's
// package.json cannot be read. The full class is wider: EVERY non-optional
// package the workspace manifest declares is something the build may import
// (`vite.config.ts` pulls `@rolldown/plugin-babel`, `@vitejs/plugin-react`,
// `@tailwindcss/vite`; `bundle-electron-main.mjs` pulls `esbuild`; the renderer
// imports the rest). A hand-maintained list drifts the moment a new import
// lands, so `checkRootInstall` unions the floor with the manifest's declared
// `dependencies` + `devDependencies` — a partial install is refused whichever
// package it happened to drop. `optionalDependencies` are excluded by design:
// npm legitimately skips them (platform-gated natives like `get-windows`).
const BUILD_CRITICAL_PACKAGES = ["vite", "katex", "electron", "electron-builder"]
export { BUILD_CRITICAL_PACKAGES }
// Resolve the way Node's own lookup does — walk `node_modules` upward — rather
// than through `require.resolve`. A package whose `exports` map does not expose
// `./package.json` is not resolvable by path even when correctly installed, and
// that must not read as "missing". Scoped names (`@scope/name`) are a nested
// directory under `node_modules`, which `join` handles.
function packageIsInstalled(name, fromDir) {
let dir = fromDir
for (;;) {
if (existsSync(join(dir, "node_modules", name, "package.json"))) return true
const parent = dirname(dir)
if (parent === dir) return false
dir = parent
}
}
// Every package the workspace manifest at `appDir` declares as required
// (`dependencies` + `devDependencies`; never `optionalDependencies`). An
// unreadable or malformed manifest yields [] — the floor still applies, and
// the build's own manifest read fails loudly on its own.
export function requiredPackages(appDir) {
try {
const manifest = JSON.parse(readFileSync(join(appDir, "package.json"), "utf8"))
return [
...Object.keys(manifest.dependencies ?? {}),
...Object.keys(manifest.devDependencies ?? {}),
]
} catch {
return []
}
}
// Pure check — returns { ok: true } or { ok: false, error: "..." }.
// Kept side-effect-free so it can be unit tested without spawning a process.
export function checkRootInstall(appDir, rootDir) {
const wanted = [...new Set([...BUILD_CRITICAL_PACKAGES, ...requiredPackages(appDir)])]
const missing = wanted.filter(pkg => !packageIsInstalled(pkg, appDir))
if (missing.length > 0) {
return {
ok: false,
error:
`the desktop build needs ${missing.join(", ")}, which the current install ` +
`does not provide. A partial root install leaves the workspace looking ` +
`present while the build cannot complete. Reinstall from the repo root: ` +
`cd ${rootDir} && npm ci`
}
}
// `vite.config.ts` aliases react/react-dom to whatever this workspace resolves,
// and React refuses to run when the two come from different installed copies
// ("Minified React error #527" — it throws before the first paint, so the app
// window stays blank). npm stays silent about the split because the hoisted
// react still satisfies react-dom's caret peer range. Fail the build loudly
// instead of shipping a white screen.
const requireFromApp = createRequire(join(appDir, "package.json"))
const installedVersion = pkg =>
JSON.parse(readFileSync(requireFromApp.resolve(`${pkg}/package.json`), "utf8")).version
let react
let reactDom
try {
react = installedVersion("react")
reactDom = installedVersion("react-dom")
} catch (err) {
// Both are in BUILD_CRITICAL_PACKAGES' spirit but not its list: they are
// checked by version, and an unreadable package.json is a broken install
// rather than an absent one. Report it as such instead of throwing.
return {
ok: false,
error: `could not read the installed react/react-dom versions (${err.message}). Reinstall from the repo root: cd ${rootDir} && npm ci`
}
}
if (react !== reactDom) {
return {
ok: false,
error:
`react@${react} / react-dom@${reactDom} version mismatch — React would fail ` +
`with error #527 and render a blank window. Pin both to the same version ` +
`in ${join(appDir, "package.json")}, then reinstall: cd ${rootDir} && npm ci`
}
}
return { ok: true }
}
function main() {
const app = resolve(import.meta.dirname, "..")
const root = resolve(app, "..", "..")
const result = checkRootInstall(app, root)
if (!result.ok) {
console.error(`✗ assert-root-install: ${result.error}`)
process.exit(1)
}
}
if (isMain(import.meta.url)) {
main()
}
@@ -0,0 +1,197 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { test } from 'vitest'
import { BUILD_CRITICAL_PACKAGES as BUILD_CRITICAL, checkRootInstall, requiredPackages } from '../scripts/assert-root-install.mjs'
// Build a throwaway repo shaped like this one: an app workspace whose
// dependencies are hoisted to the repo root, which is what the guard walks.
// `manifest` is merged into the app's package.json so tests can declare
// dependencies the guard is expected to read.
function makeTree({ rootPackages = BUILD_CRITICAL, react = '19.2.7', reactDom = '19.2.7', manifest = {} } = {}) {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-assert-root-'))
const appDir = path.join(tempRoot, 'apps', 'desktop')
fs.mkdirSync(appDir, { recursive: true })
fs.writeFileSync(path.join(appDir, 'package.json'), JSON.stringify({ name: 'desktop', ...manifest }), 'utf8')
const writePackage = (name, version) => {
const dir = path.join(tempRoot, 'node_modules', name)
fs.mkdirSync(dir, { recursive: true })
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name, version }), 'utf8')
}
for (const name of rootPackages) writePackage(name, '1.0.0')
if (react !== null) writePackage('react', react)
if (reactDom !== null) writePackage('react-dom', reactDom)
return { tempRoot, appDir }
}
test('checkRootInstall passes on a complete root install', () => {
const { tempRoot, appDir } = makeTree()
try {
assert.deepEqual(checkRootInstall(appDir, tempRoot), { ok: true })
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true })
}
})
// The regression this guard was widened for: the updater's partial `npm install`
// left katex out while vite was present, so the old vite-only check passed and
// the build died on an unresolved `katex/dist/katex.min.css` (#86443).
test('checkRootInstall fails when katex is missing but vite is present', () => {
const { tempRoot, appDir } = makeTree({
rootPackages: BUILD_CRITICAL.filter(name => name !== 'katex')
})
try {
const result = checkRootInstall(appDir, tempRoot)
assert.equal(result.ok, false)
assert.match(result.error, /katex/)
assert.match(result.error, /npm ci/)
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true })
}
})
test('checkRootInstall fails when electron is missing', () => {
const { tempRoot, appDir } = makeTree({
rootPackages: BUILD_CRITICAL.filter(name => name !== 'electron')
})
try {
const result = checkRootInstall(appDir, tempRoot)
assert.equal(result.ok, false)
assert.match(result.error, /electron/)
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true })
}
})
test('checkRootInstall reports every missing package at once', () => {
const { tempRoot, appDir } = makeTree({ rootPackages: ['vite'] })
try {
const result = checkRootInstall(appDir, tempRoot)
assert.equal(result.ok, false)
for (const name of ['katex', 'electron', 'electron-builder']) {
assert.match(result.error, new RegExp(name))
}
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true })
}
})
// The original guard's only check — kept, so widening coverage cannot silently
// drop the case it already handled.
test('checkRootInstall still fails when vite is missing', () => {
const { tempRoot, appDir } = makeTree({
rootPackages: BUILD_CRITICAL.filter(name => name !== 'vite')
})
try {
const result = checkRootInstall(appDir, tempRoot)
assert.equal(result.ok, false)
assert.match(result.error, /vite/)
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true })
}
})
test('checkRootInstall fails on a react/react-dom version split', () => {
const { tempRoot, appDir } = makeTree({ react: '19.2.7', reactDom: '19.1.0' })
try {
const result = checkRootInstall(appDir, tempRoot)
assert.equal(result.ok, false)
assert.match(result.error, /#527/)
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true })
}
})
// A package installed into the app's own node_modules rather than hoisted to the
// root is still installed. The guard walks upward like Node does, so it must not
// insist on the hoisted location.
test('checkRootInstall accepts a package nested in the app workspace', () => {
const { tempRoot, appDir } = makeTree({
rootPackages: BUILD_CRITICAL.filter(name => name !== 'katex')
})
const nested = path.join(appDir, 'node_modules', 'katex')
fs.mkdirSync(nested, { recursive: true })
fs.writeFileSync(path.join(nested, 'package.json'), JSON.stringify({ name: 'katex' }), 'utf8')
try {
assert.deepEqual(checkRootInstall(appDir, tempRoot), { ok: true })
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true })
}
})
// The class, not the four instances: the floor list is what a partial install
// has been *seen* to drop, but any declared non-optional package can be the one
// missing next (`vite.config.ts` imports `@rolldown/plugin-babel`, which the
// floor never named). The guard must read the manifest so the list cannot drift
// behind a new import.
test('checkRootInstall fails when a declared devDependency outside the floor is missing', () => {
const { tempRoot, appDir } = makeTree({
manifest: { devDependencies: { '@rolldown/plugin-babel': '1.0.0', esbuild: '1.0.0' } },
rootPackages: [...BUILD_CRITICAL, 'esbuild']
})
try {
const result = checkRootInstall(appDir, tempRoot)
assert.equal(result.ok, false)
assert.match(result.error, /@rolldown\/plugin-babel/)
assert.doesNotMatch(result.error, /esbuild/)
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true })
}
})
test('checkRootInstall fails when a declared runtime dependency is missing', () => {
const { tempRoot, appDir } = makeTree({
manifest: { dependencies: { '@vscode/codicons': '1.0.0' } }
})
try {
const result = checkRootInstall(appDir, tempRoot)
assert.equal(result.ok, false)
assert.match(result.error, /@vscode\/codicons/)
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true })
}
})
// npm skips optionalDependencies legitimately (platform-gated natives), so an
// absent optional package is not a partial install.
test('checkRootInstall ignores missing optionalDependencies', () => {
const { tempRoot, appDir } = makeTree({
manifest: { optionalDependencies: { 'get-windows': '9.3.0' } }
})
try {
assert.deepEqual(checkRootInstall(appDir, tempRoot), { ok: true })
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true })
}
})
test('checkRootInstall passes when every declared package is installed', () => {
const { tempRoot, appDir } = makeTree({
manifest: { dependencies: { '@scope/pkg': '1.0.0' }, devDependencies: { esbuild: '1.0.0' } },
rootPackages: [...BUILD_CRITICAL, '@scope/pkg', 'esbuild']
})
try {
assert.deepEqual(checkRootInstall(appDir, tempRoot), { ok: true })
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true })
}
})
// The floor is unconditional: a manifest the guard cannot parse must not turn
// the check off.
test('checkRootInstall keeps the floor when the manifest is unreadable', () => {
const { tempRoot, appDir } = makeTree({ rootPackages: ['vite'] })
fs.writeFileSync(path.join(appDir, 'package.json'), '{not json', 'utf8')
try {
assert.deepEqual(requiredPackages(appDir), [])
const result = checkRootInstall(appDir, tempRoot)
assert.equal(result.ok, false)
assert.match(result.error, /katex/)
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true })
}
})
+11
View File
@@ -0,0 +1,11 @@
/**
* Desktop bundles ship precompiled renderer assets. Returning false here tells
* electron-builder to skip the node_modules collector/install step, which
* avoids workspace dependency graph explosions and keeps packaging
* deterministic across environments. The Hermes Agent Python payload is no
* longer bundled; the Electron app fetches it at first launch via
* `install.ps1`'s stage protocol (Windows). See `electron/main.ts`.
*/
export default async function beforeBuild() {
return false
}
+158
View File
@@ -0,0 +1,158 @@
/**
* before-pack.mjs — electron-builder beforePack hook.
*
* Two responsibilities:
*
* 1. Removes any stale unpacked app directory (`appOutDir`) before
* electron-builder stages the Electron binaries into it.
*
* WHY THIS EXISTS
* ---------------
* electron-builder's final packaging step copies the stock `electron`
* binary into `release/<platform>-unpacked/` and then renames it to the
* product name (`Hermes`). If a PREVIOUS `npm run pack` was interrupted
* (Ctrl-C, OOM kill, crash, full disk) the unpacked directory is left in a
* corrupted partial state: it keeps the already-renamed `LICENSE.electron.txt`
* and the Chromium payload (.pak/.so/icudtl.dat/chrome-sandbox) but is MISSING
* the `electron` binary itself.
*
* On the next run, electron-builder sees the destination directory already
* populated, skips re-copying the binary it thinks is present, then tries to
* rename a `electron` file that no longer exists. The build dies with:
*
* ENOENT: no such file or directory, rename
* '.../release/linux-unpacked/electron' -> '.../release/linux-unpacked/Hermes'
*
* This is a hard failure with no obvious cause for the user — `hermes desktop`
* just prints "Desktop GUI build failed" and the only fix is to manually
* `rm -rf` the release directory, which a normal user has no way to know.
*
* The packaging step is not idempotent across an interrupted run, so we make
* it idempotent ourselves: wipe the target unpacked directory up front so
* electron-builder always stages into a clean tree. This is safe — the
* directory is a pure build artifact that electron-builder fully recreates
* on every pack; nothing else depends on its prior contents.
*
* Cross-platform: the same partial-state trap exists on macOS
* (the mac-unpacked Hermes.app bundle) and Windows (win-unpacked), so we
* clean whatever `appOutDir` electron-builder hands us regardless of platform.
*
* Best-effort: a cleanup failure must never mask the real build. We log and
* resolve rather than throw — worst case electron-builder hits the original
* ENOENT, which is no worse than not having this hook at all.
*
* 2. Re-stages node-pty's native files for the ACTUAL target platform/arch
* of this pack. `npm run build` already staged node-pty once for the
* host machine (see scripts/stage-native-deps.mjs), which is correct for
* single-arch builds matching the host. But electron-builder can target
* a different arch than the host (cross-build), or pack multiple archs
* from one `npm run build` (e.g. `dist:mac` => x64 + arm64). Only this
* hook knows the real per-target arch, via `context.arch` /
* `context.electronPlatformName` — so it re-stages on top of whatever
* `npm run build` left behind, per target, right before files are read
* for packing.
*
* electron-builder passes a context with:
* - appOutDir: the unpacked app directory about to be staged
* - electronPlatformName: 'win32' | 'darwin' | 'linux'
* - arch: Arch enum (0=ia32, 1=x64, 2=armv7l, 3=arm64, 4=universal)
*/
import { existsSync, rmSync, renameSync } from 'node:fs'
import path from 'node:path'
import { Arch } from 'electron-builder'
import { stageNodePty, stageGetWindows } from './stage-native-deps.mjs'
export function cleanStaleAppOutDir(appOutDir) {
if (!appOutDir || typeof appOutDir !== 'string') {
return false
}
if (!existsSync(appOutDir)) {
return false
}
// Recursive + force so a half-written tree (read-only bits, partial files)
// can't block the wipe. retry/maxRetries rides out transient EBUSY on
// Windows where an AV/indexer may briefly hold a handle.
rmSync(appOutDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })
return true
}
/**
* Windows rollback material (#69179): before wiping the previous unpacked
* tree, preserve it as `<appOutDir>.bak` — but ONLY when it holds the product
* exe (i.e. it is a previously-working build, not the corrupted partial state
* cleanStaleAppOutDir exists to remove). If the fresh pack then produces a
* Hermes.exe that Windows can't load (truncated PE from a corrupt cached
* Electron zip, wrong arch), the updater's integrity gate in
* `hermes desktop --build-only` (hermes_cli/main.py
* `_ensure_desktop_exe_launchable`) restores this .bak instead of leaving the
* user with "This app can't run on your computer".
*
* Returns true when the tree was preserved (appOutDir no longer exists), false
* when there was nothing worth preserving (caller falls through to the wipe).
* A rename failure (AV holding a handle) also returns false — the wipe is the
* safe fallback and matches pre-#69179 behavior exactly.
*/
export function preserveRollbackBackup(appOutDir, productExeName = 'Hermes.exe') {
if (!appOutDir || typeof appOutDir !== 'string' || !existsSync(appOutDir)) {
return false
}
if (!existsSync(path.join(appOutDir, productExeName))) {
// Partial/corrupt tree (interrupted prior pack) — not rollback material.
return false
}
const backupDir = `${appOutDir}.bak`
try {
rmSync(backupDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })
renameSync(appOutDir, backupDir)
return true
} catch {
return false
}
}
export default async function beforePack(context) {
const appOutDir = context && context.appOutDir
const platformName = context && context.electronPlatformName
try {
// Windows: keep the previous working build as rollback material for the
// post-build integrity gate (#69179) instead of destroying it. Falls
// through to the plain wipe when the old tree is partial/corrupt or the
// rename fails.
const productExe = `${(context && context.packager?.appInfo?.productFilename) || 'Hermes'}.exe`
if (platformName === 'win32' && preserveRollbackBackup(appOutDir, productExe)) {
console.log(`[before-pack] preserved previous unpacked dir for rollback: ${appOutDir}.bak`)
} else if (cleanStaleAppOutDir(appOutDir)) {
console.log(`[before-pack] removed stale unpacked dir before staging: ${appOutDir}`)
}
} catch (err) {
// Never fail the build over cleanup; surface why so a genuinely stuck
// directory (permissions, mount) is still diagnosable.
console.warn(`[before-pack] could not clean ${appOutDir} (${err.message}); continuing`)
}
try {
const platform = context && context.electronPlatformName
const archName = context && typeof context.arch === 'number' ? Arch[context.arch] : undefined
if (platform && archName) {
if (archName === 'universal') {
console.warn(
'[before-pack] target arch is "universal" — node-pty has no universal prebuild; ' +
'staged binary will be whichever single-arch copy npm run build left behind. ' +
'lipo-merge x64/arm64 .node files manually if you need a true universal build.'
)
} else {
await stageNodePty({ platform, arch: archName })
console.log(`[before-pack] re-staged node-pty for target ${platform}-${archName}`)
}
// The macOS helper is universal, while Windows bindings are arch-specific.
// Pass the target arch so an ARM64 package never stages an x64 binding.
stageGetWindows({ platform, arch: archName })
console.log(`[before-pack] re-staged get-windows for target ${platform}-${archName}`)
}
} catch (err) {
// This one SHOULD fail the build — a missing/wrong native binary for the
// target arch means a broken package shipped to users, which is worse
// than a build that fails loudly here.
throw new Error(`[before-pack] failed to stage native deps for this target: ${err.message}`)
}
}
+155
View File
@@ -0,0 +1,155 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { test } from 'vitest'
import beforePack, { cleanStaleAppOutDir, preserveRollbackBackup } from '../scripts/before-pack.mjs'
test('cleanStaleAppOutDir removes a populated unpacked directory', () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-before-pack-'))
try {
const appOutDir = path.join(tempRoot, 'linux-unpacked')
fs.mkdirSync(appOutDir, { recursive: true })
// Reproduce the corrupted partial state: license + payload present,
// electron binary missing — exactly what trips the ENOENT rename.
fs.writeFileSync(path.join(appOutDir, 'LICENSE.electron.txt'), 'x', 'utf8')
fs.writeFileSync(path.join(appOutDir, 'resources.pak'), 'x', 'utf8')
fs.mkdirSync(path.join(appOutDir, 'resources'), { recursive: true })
fs.writeFileSync(path.join(appOutDir, 'resources', 'app.asar'), 'x', 'utf8')
const removed = cleanStaleAppOutDir(appOutDir)
assert.equal(removed, true)
assert.equal(fs.existsSync(appOutDir), false)
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true })
}
})
test('cleanStaleAppOutDir is a no-op when the directory is absent', () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-before-pack-'))
try {
const missing = path.join(tempRoot, 'does-not-exist')
assert.equal(cleanStaleAppOutDir(missing), false)
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true })
}
})
test('cleanStaleAppOutDir ignores empty or invalid input', () => {
assert.equal(cleanStaleAppOutDir(''), false)
assert.equal(cleanStaleAppOutDir(undefined), false)
assert.equal(cleanStaleAppOutDir(null), false)
assert.equal(cleanStaleAppOutDir(42), false)
})
test('beforePack default export resolves even when cleanup throws', async () => {
// A directory path that rmSync can't remove is simulated by passing a
// context whose appOutDir is a file the hook will try (and be allowed) to
// remove; the contract under test is that the hook never rejects.
await assert.doesNotReject(beforePack({ appOutDir: '', electronPlatformName: 'linux' }))
})
// ─── Windows rollback preservation (#69179) ────────────────────────────────
test('preserveRollbackBackup moves a working build to .bak', () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-before-pack-'))
try {
const appOutDir = path.join(tempRoot, 'win-unpacked')
fs.mkdirSync(appOutDir, { recursive: true })
fs.writeFileSync(path.join(appOutDir, 'Hermes.exe'), 'MZ-old-build', 'utf8')
fs.writeFileSync(path.join(appOutDir, 'resources.pak'), 'x', 'utf8')
const preserved = preserveRollbackBackup(appOutDir, 'Hermes.exe')
assert.equal(preserved, true)
// Original slot vacated so electron-builder stages into a clean tree...
assert.equal(fs.existsSync(appOutDir), false)
// ...and the previous working build is intact under .bak for rollback.
assert.equal(
fs.readFileSync(path.join(`${appOutDir}.bak`, 'Hermes.exe'), 'utf8'),
'MZ-old-build'
)
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true })
}
})
test('preserveRollbackBackup replaces a stale .bak from an older update', () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-before-pack-'))
try {
const appOutDir = path.join(tempRoot, 'win-unpacked')
fs.mkdirSync(appOutDir, { recursive: true })
fs.writeFileSync(path.join(appOutDir, 'Hermes.exe'), 'current', 'utf8')
fs.mkdirSync(`${appOutDir}.bak`, { recursive: true })
fs.writeFileSync(path.join(`${appOutDir}.bak`, 'Hermes.exe'), 'two-updates-ago', 'utf8')
assert.equal(preserveRollbackBackup(appOutDir, 'Hermes.exe'), true)
assert.equal(fs.readFileSync(path.join(`${appOutDir}.bak`, 'Hermes.exe'), 'utf8'), 'current')
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true })
}
})
test('preserveRollbackBackup refuses a partial tree missing the product exe', () => {
// The corrupted partial state (interrupted prior pack) must NOT become
// rollback material — it is exactly what cleanStaleAppOutDir exists to wipe.
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-before-pack-'))
try {
const appOutDir = path.join(tempRoot, 'win-unpacked')
fs.mkdirSync(appOutDir, { recursive: true })
fs.writeFileSync(path.join(appOutDir, 'LICENSE.electron.txt'), 'x', 'utf8')
assert.equal(preserveRollbackBackup(appOutDir, 'Hermes.exe'), false)
// Tree untouched; the caller's wipe path handles it.
assert.equal(fs.existsSync(appOutDir), true)
assert.equal(fs.existsSync(`${appOutDir}.bak`), false)
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true })
}
})
test('preserveRollbackBackup ignores missing or invalid input', () => {
assert.equal(preserveRollbackBackup(''), false)
assert.equal(preserveRollbackBackup(undefined), false)
assert.equal(preserveRollbackBackup(null), false)
assert.equal(preserveRollbackBackup(path.join(os.tmpdir(), 'does-not-exist-xyz')), false)
})
test('beforePack on win32 preserves the previous build instead of wiping it', async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-before-pack-'))
try {
const appOutDir = path.join(tempRoot, 'win-unpacked')
fs.mkdirSync(appOutDir, { recursive: true })
fs.writeFileSync(path.join(appOutDir, 'Hermes.exe'), 'MZ-working', 'utf8')
// No packager info in the context → default 'Hermes.exe' product name.
// node-pty staging is skipped because arch is not a number here.
await beforePack({ appOutDir, electronPlatformName: 'win32' })
assert.equal(fs.existsSync(appOutDir), false)
assert.equal(
fs.readFileSync(path.join(`${appOutDir}.bak`, 'Hermes.exe'), 'utf8'),
'MZ-working'
)
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true })
}
})
test('beforePack on linux keeps the plain wipe (no .bak)', async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-before-pack-'))
try {
const appOutDir = path.join(tempRoot, 'linux-unpacked')
fs.mkdirSync(appOutDir, { recursive: true })
fs.writeFileSync(path.join(appOutDir, 'Hermes.exe'), 'x', 'utf8')
await beforePack({ appOutDir, electronPlatformName: 'linux' })
assert.equal(fs.existsSync(appOutDir), false)
assert.equal(fs.existsSync(`${appOutDir}.bak`), false)
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true })
}
})
@@ -0,0 +1,65 @@
#!/usr/bin/env node
// bundle-electron-main.mjs — bundles electron/main.ts and electron/preload.ts
// into self-contained js files in dist/ so the packaged app doesn't need
// node_modules/ or tsx at runtime.
//
// Output:
// dist/electron-main.mjs (MJS bundle — entry point for packaged app)
// dist/electron-preload.js (CJS bundle — loaded via BrowserWindow preload)
//
// `electron` and `node-pty` are external (provided by the runtime / staged
// separately via stage-native-deps).
import { build } from 'esbuild'
import { resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import { mkdirSync } from 'node:fs'
const here = dirname(fileURLToPath(import.meta.url))
const root = resolve(here, '..')
const distDir = resolve(root, 'dist')
mkdirSync(distDir, { recursive: true })
const mainEntry = resolve(root, 'electron/main.ts')
const mainOut = resolve(distDir, 'electron-main.mjs')
const preloadEntry = resolve(root, 'electron/preload.ts')
const preloadOut = resolve(distDir, 'electron-preload.js')
const external = ['electron', 'node-pty', 'get-windows', 'fs']
// Production bundles bake packaged=true so unpackaged `electron .` still
// behaves like a packaged build. Dev bundles (`--dev`) leave the env alone
// so HERMES_DESKTOP_DEV_SERVER / source-tree resolution keep working.
const isDev = process.argv.includes('--dev')
const define = isDev
? {}
: { 'process.env.HERMES_DESKTOP_IS_PACKAGED': JSON.stringify(true) }
// Bundle main.ts → dist/electron-main.mjs
await build({
entryPoints: [mainEntry],
bundle: true,
platform: 'node',
format: 'esm',
target: 'node20',
outfile: mainOut,
external,
banner: {
js: "import { createRequire } from 'module'; const require = createRequire(import.meta.url);",
},
define,
logLevel: 'info',
})
console.log(`bundled ${mainOut}${isDev ? ' (dev)' : ''}`)
// Bundle preload.ts → dist/electron-preload.js
await build({
entryPoints: [preloadEntry],
bundle: true,
platform: 'node',
format: 'cjs',
target: 'node20',
outfile: preloadOut,
external,
define,
logLevel: 'info',
})
console.log(`bundled ${preloadOut}${isDev ? ' (dev)' : ''}`)
+51
View File
@@ -0,0 +1,51 @@
// Click on a session by partial title match.
const list = await (await fetch('http://127.0.0.1:9222/json/list')).json()
const tgt = list.find(t => t.type === 'page' && t.url.startsWith('http'))
const ws = new WebSocket(tgt.webSocketDebuggerUrl)
let id = 0
const pending = new Map()
ws.addEventListener('message', ev => {
const m = JSON.parse(ev.data)
if (m.id != null && pending.has(m.id)) {
pending.get(m.id)(m)
pending.delete(m.id)
}
})
await new Promise(r => ws.addEventListener('open', r))
const send = (method, params = {}) =>
new Promise(r => {
const i = ++id
pending.set(i, r)
ws.send(JSON.stringify({ id: i, method, params }))
})
const title = process.argv[2] || 'Phaser particle'
const r = await send('Runtime.evaluate', {
expression: `
(() => {
const titleMatch = ${JSON.stringify(title)}
const all = document.querySelectorAll('button, a, div[role="button"]')
const found = [...all].find(el => (el.textContent || '').includes(titleMatch))
if (!found) return JSON.stringify({ found: false, tried: titleMatch })
found.scrollIntoView()
found.click()
return JSON.stringify({ found: true, tag: found.tagName, text: (found.textContent || '').slice(0, 80) })
})()
`,
returnByValue: true
})
console.log('click raw:', JSON.stringify(r, null, 2))
await new Promise(r => setTimeout(r, 3000))
const status = await send('Runtime.evaluate', {
expression: `JSON.stringify({
url: location.href,
hasComposer: !!document.querySelector('[data-slot="composer-rich-input"]'),
threadMessages: document.querySelectorAll('[data-slot="aui_message"]').length,
bodyTextSnippet: document.body.innerText.slice(0, 500),
title: document.title
})`,
returnByValue: true
})
console.log('after click:', status.result.value)
ws.close()
+237
View File
@@ -0,0 +1,237 @@
#!/usr/bin/env node
/**
* Launch the desktop app with a mock inference provider — no real API
* keys needed. Starts a local OpenAI-compatible server that returns a
* canned reply, writes an isolated config.yaml + .env, and launches the
* built Electron app against them.
*
* This reuses the same mock-server and config format as the E2E fixtures
* (apps/desktop/e2e/mock-server.ts + fixtures.ts), so local dev and CI
* test the same chain.
*
* Prerequisite: `npm run build` must have been run so dist/ exists.
*
* Usage:
* node scripts/dev-mock.mjs
* npm run dev:mock
*
* The mock server listens on an ephemeral port and replies to every
* chat completion with:
* "Hello from the mock inference server! The full boot chain is working."
*/
import http from 'node:http'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { spawn, spawnSync } from 'node:child_process'
const DESKTOP_ROOT = path.resolve(import.meta.dirname, '..')
const REPO_ROOT = path.resolve(DESKTOP_ROOT, '..', '..')
// ── Canned reply ───────────────────────────────────────────────────────
const CANNED_REPLY =
'Hello from the mock inference server! The full boot chain is working.'
// ── Mock server (mirrors e2e/mock-server.ts) ───────────────────────────
function startMockServer() {
return new Promise((resolve, reject) => {
const server = http.createServer((req, res) => {
res.setHeader('Access-Control-Allow-Origin', '*')
res.setHeader('Access-Control-Allow-Headers', '*')
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
if (req.method === 'OPTIONS') {
res.writeHead(204)
res.end()
return
}
if (req.method === 'GET' && req.url === '/v1/models') {
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(
JSON.stringify({
object: 'list',
data: [{ id: 'mock-model', object: 'model', created: 0, owned_by: 'mock' }],
}),
)
return
}
if (req.method === 'POST' && req.url?.startsWith('/v1/chat/completions')) {
let body = ''
req.on('data', (chunk) => { body += chunk.toString() })
req.on('end', () => {
let parsed = {}
try { parsed = JSON.parse(body) } catch { /* non-streaming */ }
const stream = parsed.stream === true
const model = parsed.model || 'mock-model'
if (stream) {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
})
const words = CANNED_REPLY.split(' ')
let i = 0
const sendChunk = () => {
if (i >= words.length) {
res.write(
`data: ${JSON.stringify({
id: 'mock-completion', object: 'chat.completion.chunk',
created: 0, model,
choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],
})}\n\n`,
)
res.write('data: [DONE]\n\n')
res.end()
return
}
const word = i === 0 ? words[i] : ' ' + words[i]
res.write(
`data: ${JSON.stringify({
id: 'mock-completion', object: 'chat.completion.chunk',
created: 0, model,
choices: [{ index: 0, delta: { content: word }, finish_reason: null }],
})}\n\n`,
)
i++
setTimeout(sendChunk, 20)
}
sendChunk()
} else {
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(
JSON.stringify({
id: 'mock-completion', object: 'chat.completion',
created: 0, model,
choices: [{
index: 0,
message: { role: 'assistant', content: CANNED_REPLY },
finish_reason: 'stop',
}],
usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 },
}),
)
}
})
req.on('error', () => { res.writeHead(400); res.end('Bad request') })
return
}
res.writeHead(404, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: 'Not found' }))
})
server.on('error', reject)
server.listen(0, '127.0.0.1', () => {
const addr = server.address()
if (addr === null || typeof addr === 'string') {
reject(new Error('Failed to get server address'))
return
}
resolve({ port: addr.port, url: `http://127.0.0.1:${addr.port}`, close: () => server.close() })
})
})
}
// ── Config + env writing (mirrors e2e/fixtures.ts) ─────────────────────
function createSandbox() {
const root = fs.mkdtempSync(path.join(os.tmpdir(), `hermes-dev-mock-${Date.now()}`))
const hermesHome = path.join(root, 'hermes-home')
const userDataDir = path.join(root, 'electron-user-data')
fs.mkdirSync(hermesHome, { recursive: true })
fs.mkdirSync(userDataDir, { recursive: true })
return { root, hermesHome, userDataDir, cleanup: () => fs.rmSync(root, { recursive: true, force: true }) }
}
function writeMockConfig(hermesHome, mockUrl) {
fs.writeFileSync(
path.join(hermesHome, 'config.yaml'),
`# Auto-generated by dev-mock.mjs
model:
default: mock-model
provider: mock
providers:
mock:
api: ${mockUrl}/v1
name: Mock
api_mode: chat_completions
key_env: MOCK_API_KEY
models:
mock-model: {}
context_length: 4096
`,
'utf8',
)
fs.writeFileSync(path.join(hermesHome, '.env'), 'MOCK_API_KEY=e2e-mock-key\n', 'utf8')
}
// ── Electron launch ────────────────────────────────────────────────────
function findElectron() {
const local = path.join(REPO_ROOT, 'node_modules', 'electron', 'dist', 'electron')
if (fs.existsSync(local)) return local
const r = spawnSync('which', ['electron'], { encoding: 'utf8' })
if (r.status === 0 && r.stdout.trim()) return r.stdout.trim()
throw new Error('Electron binary not found. Run "npm install" from the repo root.')
}
function assertDistBuilt() {
const electronMain = path.join(DESKTOP_ROOT, 'dist', 'electron-main.mjs')
const indexHtml = path.join(DESKTOP_ROOT, 'dist', 'index.html')
if (!fs.existsSync(electronMain) || !fs.existsSync(indexHtml)) {
throw new Error(
`Desktop dist not built. Run 'cd apps/desktop && npm run build' first.\n` +
`Missing: ${electronMain}`,
)
}
}
// ── Main ───────────────────────────────────────────────────────────────
async function main() {
assertDistBuilt()
console.log('Starting mock inference server...')
const mock = await startMockServer()
console.log(` Mock server: ${mock.url}`)
const sandbox = createSandbox()
writeMockConfig(sandbox.hermesHome, mock.url)
console.log(` HERMES_HOME: ${sandbox.hermesHome}`)
const electronBin = findElectron()
const env = {
...process.env,
HERMES_HOME: sandbox.hermesHome,
HERMES_DESKTOP_USER_DATA_DIR: sandbox.userDataDir,
HERMES_DESKTOP_IGNORE_EXISTING: '1',
HERMES_DESKTOP_HERMES_ROOT: REPO_ROOT,
HERMES_DESKTOP_APP_NAME: `HermesDevMock-${Date.now()}`,
}
console.log('Launching Electron...')
const child = spawn(electronBin, [DESKTOP_ROOT, '--disable-gpu', '--no-sandbox'], {
env,
cwd: DESKTOP_ROOT,
stdio: 'inherit',
})
child.on('exit', (code) => {
mock.close()
sandbox.cleanup()
process.exit(code ?? 0)
})
}
main().catch((err) => {
console.error(err)
process.exit(1)
})
+22
View File
@@ -0,0 +1,22 @@
#!/usr/bin/env node
// Launch the desktop renderer with HMR disabled so the React Fast Refresh
// preamble path is skipped. This sidesteps a current Vite 8 / plugin-react 6
// bug where the preamble script is not injected into index.html → renderer
// throws "$RefreshReg$ is not defined" on every TSX module → React tree
// never mounts.
//
// We're not trying to use HMR while profiling typing lag anyway. Hermes desktop
// boots, you type, profiler measures. HMR off is fine.
//
// Usage: node apps/desktop/scripts/dev-no-hmr.mjs
// (then in another shell, run electron --remote-debugging-port=9222 .)
import { createServer } from 'vite'
const server = await createServer({
configFile: new URL('../vite.config.ts', import.meta.url).pathname,
root: new URL('../', import.meta.url).pathname,
server: { hmr: false, host: '127.0.0.1', port: 5174, strictPort: true }
})
await server.listen()
server.printUrls()
+25
View File
@@ -0,0 +1,25 @@
// Is the tree-split preview path actually active in the running renderer?
// Checks the served source (what vite compiled) rather than guessing.
import { attach } from './perf/lib/launch.mjs'
const { cdp, teardown } = await attach({ port: 9222 })
try {
await cdp.send('Runtime.enable')
const out = await cdp.eval(`(async () => {
const res = await fetch('/src/components/pane-shell/tree/renderer/tree-split.tsx')
const src = await res.text()
return JSON.stringify({
previewShift: src.includes('previewShift'),
adaptiveFloor: (await (await fetch('/src/app/session/hooks/use-message-stream/index.ts')).text()).includes('adaptiveFloor'),
structuralSignature: (await (await fetch('/src/components/assistant-ui/thread/list.tsx')).text()).includes('structuralSignature'),
sharedRO: (await (await fetch('/src/hooks/use-resize-observer.ts')).text()).includes('sharedObserver'),
rootTipProvider: (await (await fetch('/src/main.tsx')).text()).includes('RootTooltipProvider')
})
})()`)
console.log(out)
} finally {
teardown?.()
}
+158
View File
@@ -0,0 +1,158 @@
// Who re-renders the transcript during a sash drag?
//
// Standalone probe, not a benchmark: seeds tiles, then drags the sash while
// recording (a) render attribution and (b) every nanostores atom that notifies
// during the gesture. The idle-cost scenario proved the transcript re-renders
// ~18x above baseline during a drag but that the sash HANDLER is not the cause
// (identical counts at 0px and 60px displacement) — so this names the store
// that actually fires.
//
// node scripts/perf/diag-drag-churn.mjs [--port 9222]
import { attach } from './perf/lib/launch.mjs'
import { sleep } from './perf/lib/cdp.mjs'
const TILES = 5
const TURNS = 20
const setup = `
(() => {
const hook = window.__HERMES_SESSION_TILES__
if (!hook) return 'no-hook'
const turn = (sid, i) => ([
{ id: sid + '-u' + i, role: 'user', timestamp: Date.now(),
parts: [{ type: 'text', text: 'Question ' + i }] },
{ id: sid + '-a' + i, role: 'assistant', timestamp: Date.now(), pending: false,
parts: [{ type: 'text', text: '## Finding ' + i + '\\n\\nSome prose with **bold** and \`code\`.\\n' }] }
])
window.__D__ = { ids: [] }
for (let n = 1; n <= ${TILES}; n++) {
const sid = 'diag-tile-' + n
const rid = 'diag-rt-' + n
const messages = []
for (let i = 0; i < ${TURNS}; i++) messages.push(...turn(sid, i))
messages.push({ id: sid + '-stream', role: 'assistant', timestamp: Date.now(), pending: true,
parts: [{ type: 'text', text: 'Working.' }] })
window.__D__.ids.push({ sid, rid })
hook.open(sid, 'center')
hook.patch(sid, { runtimeId: rid })
hook.publish(rid, {
storedSessionId: sid, messages, branch: '', cwd: '', model: '', provider: '',
reasoningEffort: '', serviceTier: '', fast: false, yolo: false, personality: '',
busy: true, awaitingResponse: false, streamId: sid + '-stream', sawAssistantPayload: true,
pendingBranchGroup: null, interrupted: false, interimBoundaryPending: false,
needsInput: false, turnStartedAt: Date.now(), usage: null
})
}
return 'ok'
})()
`
const reveal = sid => `window.__HERMES_LAYOUT_TREE__.reveal(${JSON.stringify(`session-tile:${sid}`)})`
// Drag, recording renders AND atom notifications together.
const DRAG = `
(async () => {
const rc = window.__RENDER_COUNTS__
const ac = window.__ATOM_CHURN__
rc.start(); ac.start()
const handle = document.querySelector('[role="separator"]')
if (!handle) { rc.stop(); ac.stop(); return JSON.stringify({ error: 'no sash' }) }
const box = handle.getBoundingClientRect()
const y = box.top + box.height / 2
const x0 = box.left + box.width / 2
let x = x0
const opts = { bubbles: true, cancelable: true, pointerId: 1, pointerType: 'mouse', isPrimary: true, button: 0, buttons: 1 }
handle.dispatchEvent(new PointerEvent('pointerdown', { ...opts, clientX: x, clientY: y }))
for (let i = 0; i < 30; i++) {
x += 2
window.dispatchEvent(new PointerEvent('pointermove', { ...opts, clientX: x, clientY: y }))
await new Promise(r => requestAnimationFrame(r))
}
window.dispatchEvent(new PointerEvent('pointerup', { ...opts, buttons: 0, clientX: x, clientY: y }))
rc.stop(); ac.stop()
const all = rc.report(400)
const named = n => all.find(r => r.name === n) || null
return JSON.stringify({
moved: Math.round(x - x0),
commits: rc.commits(),
renders: rc.report(14),
// The suspects: who at the TOP of the transcript tree re-rendered?
chain: ['ChatView', 'ChatRuntimeBoundary', 'AuiProvider', 'Thread', 'SessionTile', 'TileChat',
'LayoutTreeRoot', 'TreeNode', 'TreeSplit', 'TreeGroup', 'SessionView', 'PaneShell']
.map(n => ({ name: n, hit: named(n) })).filter(x => x.hit),
atoms: ac.report(30)
})
})()
`
const CLEANUP = `
(() => {
if (window.__D__) {
for (const { sid, rid } of window.__D__.ids) {
const s = window.__HERMES_SESSION_TILES__.states()
window.__HERMES_SESSION_TILES__.publish(rid, { ...s[rid], busy: false, streamId: null })
window.__HERMES_SESSION_TILES__.close(sid)
}
window.__D__ = null
}
return 'cleaned'
})()
`
const port = Number(process.argv.includes('--port') ? process.argv[process.argv.indexOf('--port') + 1] : 9222)
const { cdp, teardown } = await attach({ port })
try {
await cdp.send('Runtime.enable')
const ok = await cdp.eval(setup)
if (ok !== 'ok') {
throw new Error(`setup failed: ${ok}`)
}
for (let n = 1; n <= TILES; n++) {
await cdp.eval(reveal(`diag-tile-${n}`))
await sleep(300)
}
await sleep(1500)
const data = JSON.parse(await cdp.eval(DRAG))
await cdp.eval(CLEANUP)
console.log(`moved ${data.moved}px, ${data.commits} commits\n`)
console.log('RENDERS during drag:')
for (const r of data.renders) {
console.log(
` ${r.name.padEnd(28)} r=${String(r.renders).padStart(6)} wasted=${String(r.wasted).padStart(6)} ` +
`props=${String(r.propsChanged).padStart(5)} state=${String(r.stateChanged).padStart(5)} ` +
`ctx=${String(r.contextChanged ?? 0).padStart(4)} ms=${r.totalMs}`
)
}
console.log('\nTRANSCRIPT CHAIN (who above the messages re-rendered):')
for (const { name, hit } of data.chain) {
console.log(
` ${name.padEnd(24)} r=${String(hit.renders).padStart(6)} wasted=${String(hit.wasted).padStart(6)} ` +
`props=${String(hit.propsChanged).padStart(5)} state=${String(hit.stateChanged).padStart(5)} ms=${hit.totalMs}`
)
}
console.log('\nATOMS that notified during drag:')
for (const a of data.atoms) {
console.log(
` ${a.name.padEnd(26)} notifies=${String(a.notifies).padStart(5)} wasted=${String(a.wasted).padStart(5)} ` +
`fanout=${String(a.fanout).padStart(6)} peakListeners=${a.peakListeners}`
)
}
} finally {
teardown?.()
}
+217
View File
@@ -0,0 +1,217 @@
// What is the drag actually spending time on?
//
// The render counters proved React is no longer the cost (commits 83 -> 12
// after the $layoutTree fix) yet drag fps stayed ~3 while p95 halved. That
// pattern says a fixed per-frame floor outside React. This takes a real CDP
// trace of one sash drag and prints the category split — Recalculate Style,
// Layout, Paint, Scripting — so the next fix targets the actual cost instead
// of the next plausible-looking thing.
//
// node scripts/diag-drag-trace.mjs [--port 9222] [--tiles 5]
import { attach } from './perf/lib/launch.mjs'
import { sleep } from './perf/lib/cdp.mjs'
const arg = (name, fallback) => {
const i = process.argv.indexOf(`--${name}`)
return i === -1 ? fallback : process.argv[i + 1]
}
const port = Number(arg('port', 9222))
const TILES = Number(arg('tiles', 5))
const TURNS = Number(arg('turns', 20))
const setup = `
(() => {
const hook = window.__HERMES_SESSION_TILES__
if (!hook) return 'no-hook'
const turn = (sid, i) => ([
{ id: sid + '-u' + i, role: 'user', timestamp: Date.now(),
parts: [{ type: 'text', text: 'Question ' + i }] },
{ id: sid + '-a' + i, role: 'assistant', timestamp: Date.now(), pending: false,
parts: [{ type: 'text', text: '## Finding ' + i + '\\n\\nProse with **bold** and \`code\`.\\n' }] }
])
window.__T__ = { ids: [] }
for (let n = 1; n <= ${TILES}; n++) {
const sid = 'trace-tile-' + n
const rid = 'trace-rt-' + n
const messages = []
for (let i = 0; i < ${TURNS}; i++) messages.push(...turn(sid, i))
messages.push({ id: sid + '-stream', role: 'assistant', timestamp: Date.now(), pending: true,
parts: [{ type: 'text', text: 'Working.' }] })
window.__T__.ids.push({ sid, rid })
hook.open(sid, 'center')
hook.patch(sid, { runtimeId: rid })
hook.publish(rid, {
storedSessionId: sid, messages, branch: '', cwd: '', model: '', provider: '',
reasoningEffort: '', serviceTier: '', fast: false, yolo: false, personality: '',
busy: true, awaitingResponse: false, streamId: sid + '-stream', sawAssistantPayload: true,
pendingBranchGroup: null, interrupted: false, interimBoundaryPending: false,
needsInput: false, turnStartedAt: Date.now(), usage: null
})
}
return 'ok'
})()
`
const reveal = sid => `window.__HERMES_LAYOUT_TREE__.reveal(${JSON.stringify(`session-tile:${sid}`)})`
// Drive the sash WITHOUT awaiting rAF: a slow app would stretch a rAF-paced
// loop and make the window itself a function of the slowness. Fixed wall-clock
// pacing keeps the trace window comparable run to run.
const DRAG = `
(async () => {
const handle = document.querySelector('[role="separator"]')
if (!handle) return 'none'
const box = handle.getBoundingClientRect()
const y = box.top + box.height / 2
const x0 = box.left + box.width / 2
let x = x0
const opts = { bubbles: true, cancelable: true, pointerId: 1, pointerType: 'mouse', isPrimary: true, button: 0, buttons: 1 }
handle.dispatchEvent(new PointerEvent('pointerdown', { ...opts, clientX: x, clientY: y }))
for (let i = 0; i < 40; i++) {
x += (i < 20 ? 3 : -3)
window.dispatchEvent(new PointerEvent('pointermove', { ...opts, clientX: x, clientY: y }))
await new Promise(r => setTimeout(r, 16))
}
window.dispatchEvent(new PointerEvent('pointerup', { ...opts, buttons: 0, clientX: x, clientY: y }))
return 'dragged'
})()
`
const CLEANUP = `
(() => {
if (window.__T__) {
for (const { sid, rid } of window.__T__.ids) {
const s = window.__HERMES_SESSION_TILES__.states()
window.__HERMES_SESSION_TILES__.publish(rid, { ...s[rid], busy: false, streamId: null })
window.__HERMES_SESSION_TILES__.close(sid)
}
window.__T__ = null
}
return 'cleaned'
})()
`
const { cdp, teardown } = await attach({ port })
try {
await cdp.send('Runtime.enable')
const ok = await cdp.eval(setup)
if (ok !== 'ok') {
throw new Error(`setup failed: ${ok}`)
}
for (let n = 1; n <= TILES; n++) {
await cdp.eval(reveal(`trace-tile-${n}`))
await sleep(300)
}
await sleep(1500)
// Collect trace events for the drag window only. `cdp.on` is the client's
// only event API (no `once`), so completion is signalled through a flag.
const events = []
let complete = false
cdp.on('Tracing.dataCollected', params => events.push(...(params.value ?? [])))
cdp.on('Tracing.tracingComplete', () => {
complete = true
})
await cdp.send('Tracing.start', {
transferMode: 'ReportEvents',
traceConfig: { includedCategories: ['devtools.timeline', 'blink.user_timing'] }
})
const dragged = await cdp.eval(DRAG)
await cdp.send('Tracing.end')
for (let waited = 0; !complete && waited < 10000; waited += 200) {
await sleep(200)
}
await cdp.eval(CLEANUP)
// Sum self-time per timeline category. Nested events would double-count, so
// attribute each event's duration minus the duration of its direct children.
const INTERESTING = new Set([
'UpdateLayoutTree', // Recalculate Style
'Layout',
'Paint',
'PaintImage',
'Layerize',
'UpdateLayer',
'CompositeLayers',
'FunctionCall',
'EvaluateScript',
'TimerFire',
'EventDispatch',
'HitTest',
'ParseHTML',
'CommitLoad'
])
const totals = new Map()
let traced = 0
for (const e of events) {
if (e.ph !== 'X' || typeof e.dur !== 'number') {
continue
}
traced += 1
const name = e.name
if (!INTERESTING.has(name)) {
continue
}
totals.set(name, (totals.get(name) ?? 0) + e.dur / 1000)
}
console.log(`drag=${dragged} trace events=${events.length} (complete=${traced})\n`)
console.log('TIMELINE COST (ms, total duration by event):')
const rows = [...totals.entries()].sort((a, b) => b[1] - a[1])
if (rows.length === 0) {
console.log(' (no timeline events — category filter or tracing domain unavailable)')
}
for (const [name, ms] of rows) {
console.log(` ${name.padEnd(20)} ${ms.toFixed(1)}ms`)
}
const style = totals.get('UpdateLayoutTree') ?? 0
const layout = totals.get('Layout') ?? 0
const script = (totals.get('FunctionCall') ?? 0) + (totals.get('EvaluateScript') ?? 0) + (totals.get('TimerFire') ?? 0)
console.log(`\nVERDICT: style=${style.toFixed(0)}ms layout=${layout.toFixed(0)}ms script=${script.toFixed(0)}ms`)
// Script dominates -> name the functions. Timeline FunctionCall events carry
// the callsite in args.data, so the top offenders can be attributed without
// a separate CPU profile.
const byFn = new Map()
for (const e of events) {
if (e.ph !== 'X' || e.name !== 'FunctionCall' || typeof e.dur !== 'number') {
continue
}
const d = e.args?.data ?? {}
const key = `${d.functionName || '(anonymous)'} @ ${(d.url || '?').split('/').pop()}:${d.lineNumber ?? '?'}`
byFn.set(key, (byFn.get(key) ?? 0) + e.dur / 1000)
}
console.log('\nTOP SCRIPT CALLSITES (ms):')
for (const [name, ms] of [...byFn.entries()].sort((a, b) => b[1] - a[1]).slice(0, 15)) {
console.log(` ${ms.toFixed(1).padStart(8)} ${name}`)
}
} finally {
teardown?.()
}
+115
View File
@@ -0,0 +1,115 @@
// Wrap the thread scroller's properties and observe pin/scroll/RO events
// in real time during a submit, then print the timeline.
const list = await (await fetch('http://127.0.0.1:9222/json/list')).json()
const tgt = list.find(t => t.type === 'page' && t.url.startsWith('http'))
const ws = new WebSocket(tgt.webSocketDebuggerUrl)
let id = 0
const pending = new Map()
ws.addEventListener('message', ev => {
const m = JSON.parse(ev.data)
if (m.id != null && pending.has(m.id)) {
pending.get(m.id)(m)
pending.delete(m.id)
}
})
await new Promise(r => ws.addEventListener('open', r))
const send = (m, p = {}) =>
new Promise(r => {
const i = ++id
pending.set(i, r)
ws.send(JSON.stringify({ id: i, method: m, params: p }))
})
const evalP = async expr => {
const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true })
if (r.result?.exceptionDetails) throw new Error(r.result.exceptionDetails.text)
return r.result.result.value
}
await evalP(`(() => {
const v = document.querySelector('[data-slot="aui_thread-viewport"]')
if (v) v.scrollTop = v.scrollHeight
})()`)
await new Promise(r => setTimeout(r, 300))
await evalP(`(() => {
const el = document.querySelector('[data-slot="composer-rich-input"]')
el.focus()
const r = document.createRange(); r.selectNodeContents(el); r.collapse(false)
window.getSelection().removeAllRanges(); window.getSelection().addRange(r)
})()`)
const text = 'short follow-up'
for (const c of text) {
await send('Input.dispatchKeyEvent', { type: 'char', text: c, unmodifiedText: c })
await new Promise(r => setTimeout(r, 10))
}
await new Promise(r => setTimeout(r, 300))
// Hook into the viewport scrollTop setter + scroll + RO so we see every event
await evalP(`(() => {
const v = document.querySelector('[data-slot="aui_thread-viewport"]')
const events = []
window.__threadEvents = events
const t0 = performance.now()
const push = (kind, detail) => events.push({ t: performance.now() - t0, kind, ...detail })
// intercept scrollTop writes
const desc = Object.getOwnPropertyDescriptor(Element.prototype, 'scrollTop')
Object.defineProperty(v, 'scrollTop', {
get() { return desc.get.call(this) },
set(val) {
push('scrollTop=', { val, fromScrollHeight: this.scrollHeight, stackTop: (new Error()).stack.split('\\n').slice(2, 5).map(s => s.trim()).join(' | ') })
desc.set.call(this, val)
},
configurable: true
})
// scroll event
v.addEventListener('scroll', () => {
push('scroll', { scrollTop: v.scrollTop, scrollHeight: v.scrollHeight })
}, { passive: true, capture: true })
// RO on the viewport itself
const ro = new ResizeObserver((entries) => {
for (const e of entries) {
push('RO', { target: e.target.getAttribute('data-slot') || e.target.tagName, h: e.contentRect.height })
}
})
ro.observe(v)
if (v.firstElementChild) ro.observe(v.firstElementChild)
// mutationobserver on the viewport
const mo = new MutationObserver((muts) => {
push('mut', { count: muts.length, added: muts.reduce((s, m) => s + m.addedNodes.length, 0), removed: muts.reduce((s, m) => s + m.removedNodes.length, 0) })
})
mo.observe(v, { childList: true, subtree: true, characterData: true })
window.__teardown = () => { ro.disconnect(); mo.disconnect() }
return true
})()`)
// fire Enter
await send('Input.dispatchKeyEvent', {
type: 'rawKeyDown', windowsVirtualKeyCode: 13, key: 'Enter', code: 'Enter', text: '\r', unmodifiedText: '\r'
})
await send('Input.dispatchKeyEvent', { type: 'keyUp', windowsVirtualKeyCode: 13, key: 'Enter', code: 'Enter' })
await new Promise(r => setTimeout(r, 1200))
const events = JSON.parse(await evalP(`JSON.stringify(window.__threadEvents || [])`))
console.log(`\n${events.length} events:`)
for (const e of events) {
const t = String(e.t.toFixed(0)).padStart(5)
const { kind, t: _t, ...rest } = e
console.log(` ${t}ms ${kind.padEnd(12)} ${JSON.stringify(rest)}`)
}
await evalP(`window.__teardown?.()`)
// Cancel running agent
await evalP(`(() => {
for (const b of document.querySelectorAll('button')) {
if ((b.getAttribute('aria-label') || '').toLowerCase().includes('stop')) { b.click(); return 'stopped' }
}
})()`)
ws.close()
+47
View File
@@ -0,0 +1,47 @@
// Typing latency, isolated: keystroke -> next paint, with and without an
// active stream. Distinguishes "input is slow" from "the frame budget is
// consumed by streaming flushes" — the fix differs completely.
import { attach } from './perf/lib/launch.mjs'
const { cdp, teardown } = await attach({ port: 9222 })
const TYPE = `
(async () => {
const el = [...document.querySelectorAll('[contenteditable="true"]')].find(e => e.offsetParent)
if (!el) return JSON.stringify({ error: 'no composer' })
el.focus()
const perKey = []
for (let i = 0; i < 30; i++) {
const ch = 'abcdefghij'[i % 10]
const t0 = performance.now()
el.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: ch }))
el.textContent += ch
el.dispatchEvent(new InputEvent('input', { bubbles: true, data: ch, inputType: 'insertText' }))
el.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, key: ch }))
await new Promise(r => requestAnimationFrame(r))
perKey.push(performance.now() - t0)
// Human-ish 80ms cadence so streaming flushes interleave realistically.
await new Promise(r => setTimeout(r, 80))
}
el.textContent = ''
el.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'deleteContentBackward' }))
const sorted = [...perKey].sort((a, b) => a - b)
const pct = p => sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * p))]
const busy = (() => { try { return document.querySelectorAll('[data-status="running"]').length } catch { return -1 } })()
return JSON.stringify({
keyToPaint_p50: Math.round(pct(0.5) * 10) / 10,
keyToPaint_p95: Math.round(pct(0.95) * 10) / 10,
worst: Math.round(sorted[sorted.length - 1] * 10) / 10,
over16: perKey.filter(f => f > 16.7).length,
over33: perKey.filter(f => f > 33).length,
streamingParts: busy
})
})()
`
try {
await cdp.send('Runtime.enable')
console.log(await cdp.eval(TYPE))
} finally {
teardown?.()
}
+21
View File
@@ -0,0 +1,21 @@
// Quick state probe of the running hgui instance via CDP.
import { attach } from './perf/lib/launch.mjs'
const { cdp, teardown } = await attach({ port: 9222 })
try {
await cdp.send('Runtime.enable')
const state = await cdp.eval(`(() => {
const rc = !!window.__RENDER_COUNTS__
const pl = !!window.__PERF_LIVE__
const tiles = window.__HERMES_SESSION_TILES__ ? Object.keys(window.__HERMES_SESSION_TILES__.states()).length : -1
const gw = document.querySelector('[data-slot="statusbar"]')?.textContent?.slice(0, 120) ?? '(no statusbar)'
const sidebarRows = document.querySelectorAll('[data-slot="sidebar"] [data-session-id], [data-tree-group] a').length
return JSON.stringify({ rc, pl, tiles, gw, sidebarRows, title: document.title, url: location.href.slice(0, 80) })
})()`)
console.log(state)
} finally {
teardown?.()
}
+61
View File
@@ -0,0 +1,61 @@
#!/usr/bin/env node
// Robust A/B: measure each surface N times, report median wasted renders.
// Reduces timing noise from stream ticks landing on different clicks.
import WebSocket from 'ws'
const RUNS = 3
let msgId = 1
function send(ws, method, params = {}) {
const id = msgId++
return new Promise((resolve, reject) => {
const h = (data) => { const m = JSON.parse(data); if (m.id === id) { ws.off('message', h); m.error ? reject(m.error) : resolve(m.result) } }
ws.on('message', h); ws.send(JSON.stringify({ id, method, params }))
})
}
async function ev(ws, e) { return (await send(ws, 'Runtime.evaluate', { expression: e, returnByValue: true, awaitPromise: true }))?.result?.value }
const sleep = ms => new Promise(r => setTimeout(r, ms))
const median = arr => { const s = [...arr].sort((a,b)=>a-b); return s[Math.floor(s.length/2)] }
async function wsUrl() {
const d = await (await fetch('http://127.0.0.1:9222/json/list')).json()
return d.find(t => t.type === 'page' && (t.url||'').includes('5174')).webSocketDebuggerUrl
}
async function measureOnce(ws, setup, holdMs) {
await setup(ws)
await sleep(300)
await ev(ws, `__RENDER_COUNTS__.start(); true`)
await sleep(holdMs)
const rep = JSON.parse(await ev(ws, `JSON.stringify(__RENDER_COUNTS__.report())`))
return rep.reduce((s, c) => s + c.wasted, 0)
}
async function main() {
const ws = new WebSocket(await wsUrl())
await new Promise((res, rej) => { ws.on('open', res); ws.on('error', rej) })
await send(ws, 'Runtime.enable')
const surfaces = [
['Artifacts', ws => ev(ws, `window.location.hash='#/artifacts'; true`), 2000],
['Messaging', ws => ev(ws, `window.location.hash='#/messaging'; true`), 2000],
['Cron', ws => ev(ws, `window.location.hash='#/cron'; true`), 2000],
['Profiles', ws => ev(ws, `window.location.hash='#/profiles'; true`), 2000],
['Agents', ws => ev(ws, `window.location.hash='#/agents'; true`), 2000],
['Starmap', ws => ev(ws, `window.location.hash='#/starmap'; true`), 2000],
['Webhooks', ws => ev(ws, `window.location.hash='#/webhooks'; true`), 2000],
['CommandCenter/System', async ws => { await ev(ws, `window.location.hash='#/command-center'; true`); await sleep(400); await ev(ws, `Array.from(document.querySelectorAll('button')).find(b=>b.textContent?.trim()==='System')?.click(); true`) }, 2000],
]
const label = process.argv[2] || 'RUN'
console.log(`\n=== ${label} (median of ${RUNS}, wasted renders, 2s idle) ===`)
for (const [name, setup, hold] of surfaces) {
const runs = []
for (let i = 0; i < RUNS; i++) {
runs.push(await measureOnce(ws, setup, hold))
await ev(ws, `window.location.hash='#/'; true`); await sleep(300)
}
console.log(` ${name.padEnd(24)} median ${String(median(runs)).padStart(6)} (runs: ${runs.join(', ')})`)
}
ws.close()
}
main().catch(e => { console.error(e); process.exit(1) })
+161
View File
@@ -0,0 +1,161 @@
#!/usr/bin/env node
// CDP probe: measure render churn on overlay surfaces (cmdk, settings, command-center, skills)
// against the user's live instance on :9222. Read-only — never closes or navigates away.
import WebSocket from 'ws'
const CDP_URL = 'ws://127.0.0.1:9222'
const TARGET_GLOB = '/devtools/page/'
let msgId = 1
function send(ws, method, params = {}) {
const id = msgId++
return new Promise((resolve, reject) => {
const handler = (data) => {
const msg = JSON.parse(data.toString())
if (msg.id === id) {
ws.off('message', handler)
if (msg.error) reject(new Error(JSON.stringify(msg.error)))
else resolve(msg.result)
}
}
ws.on('message', handler)
ws.send(JSON.stringify({ id, method, params }))
})
}
async function getRendererTarget() {
const resp = await fetch('http://127.0.0.1:9222/json/list')
const targets = await resp.json()
// Find the renderer target (not devtools)
return targets.find(t => t.url?.startsWith('http://127.0.0.1:5174') || t.url?.includes('5174'))
?? targets.find(t => t.type === 'page' && !t.url.startsWith('devtools'))
}
async function evalInPage(ws, expr) {
const result = await send(ws, 'Runtime.evaluate', {
expression: expr,
returnByValue: true,
awaitPromise: true,
})
return result?.result?.value
}
async function main() {
const target = await getRendererTarget()
if (!target) {
console.error('No renderer target found')
process.exit(1)
}
console.log(`Target: ${target.url}`)
const ws = new WebSocket(target.webSocketDebuggerUrl)
await new Promise((resolve, reject) => {
ws.on('open', resolve)
ws.on('error', reject)
})
await send(ws, 'Runtime.enable')
// Check if __RENDER_COUNTS__ is available
const hasCounter = await evalInPage(ws, 'typeof __RENDER_COUNTS__')
console.log(`__RENDER_COUNTS__ available: ${hasCounter}`)
if (hasCounter !== 'object') {
console.log('Render counter not loaded — checking perf-live...')
const hasPerfLive = await evalInPage(ws, 'typeof __PERF_LIVE__')
console.log(`__PERF_LIVE__ available: ${hasPerfLive}`)
}
// 1. Baseline: open cmdk, type a few chars, measure
console.log('\n=== CmdK Palette ===')
await evalInPage(ws, `
window.__renderBaseline = {}
if (typeof __RENDER_COUNTS__ === 'object' && __RENDER_COUNTS__) {
__RENDER_COUNTS__.reset()
}
// Open cmdk via keyboard shortcut
const evt = new KeyboardEvent('keydown', { key: 'k', metaKey: true, bubbles: true })
document.dispatchEvent(evt)
true
`)
await new Promise(r => setTimeout(r, 500))
// Type some characters
for (const ch of ['s', 'e', 't', 't', 'i', 'n', 'g']) {
await evalInPage(ws, `
const el = document.querySelector('[cmdk-input]') || document.querySelector('input[placeholder]')
if (el) {
el.focus()
el.value = el.value + '${ch}'
el.dispatchEvent(new Event('input', { bubbles: true }))
}
true
`)
await new Promise(r => setTimeout(r, 80))
}
await new Promise(r => setTimeout(r, 300))
const cmdkCounts = await evalInPage(ws, `
__RENDER_COUNTS__?.snapshot?.() ?? __RENDER_COUNTS__?.counts ?? 'no counter'
`)
console.log('CmdK render counts after typing "setting":', JSON.stringify(cmdkCounts, null, 2))
// Close cmdk
await evalInPage(ws, `new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }); document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); true`)
await new Promise(r => setTimeout(r, 300))
// 2. Command Center — measure renders while on System tab
console.log('\n=== Command Center (System tab) ===')
await evalInPage(ws, `
if (typeof __RENDER_COUNTS__ === 'object' && __RENDER_COUNTS__) {
__RENDER_COUNTS__.reset()
}
// Navigate to command center system section
const link = Array.from(document.querySelectorAll('a, button')).find(el => el.textContent?.includes('System') || el.textContent?.includes('Command Center'))
if (link) link.click()
true
`)
await new Promise(r => setTimeout(r, 1000))
const ccCounts = await evalInPage(ws, `
__RENDER_COUNTS__?.snapshot?.() ?? __RENDER_COUNTS__?.counts ?? 'no counter'
`)
console.log('Command Center render counts (after 1s on System tab):', JSON.stringify(ccCounts, null, 2))
// 3. Settings page — measure renders on nav
console.log('\n=== Settings Page ===')
await evalInPage(ws, `
if (typeof __RENDER_COUNTS__ === 'object' && __RENDER_COUNTS__) {
__RENDER_COUNTS__.reset()
}
// Navigate to settings
const link = Array.from(document.querySelectorAll('a, button')).find(el => el.textContent?.includes('Settings'))
if (link) link.click()
true
`)
await new Promise(r => setTimeout(r, 1000))
// Click through a few nav items
for (const label of ['Appearance', 'Gateway', 'Keys', 'About']) {
await evalInPage(ws, `
const el = Array.from(document.querySelectorAll('button')).find(b => b.textContent?.trim() === '${label}')
if (el) el.click()
true
`)
await new Promise(r => setTimeout(r, 150))
}
const settingsCounts = await evalInPage(ws, `
__RENDER_COUNTS__?.snapshot?.() ?? __RENDER_COUNTS__?.counts ?? 'no counter'
`)
console.log('Settings render counts (after clicking 4 nav items):', JSON.stringify(settingsCounts, null, 2))
ws.close()
console.log('\nDone.')
}
main().catch(err => {
console.error('Error:', err)
process.exit(1)
})
+201
View File
@@ -0,0 +1,201 @@
#!/usr/bin/env node
// Comprehensive overlay render-churn measurement on the live instance.
// Measures: cmdk root + submenus, settings, command center, capabilities, system overlays.
// Read-only — never closes the app, only navigates and clicks.
import WebSocket from 'ws'
const WS_URL = 'ws://127.0.0.1:9222/devtools/page/6E095DBE024BD280C674D00023C01201'
let msgId = 1
function send(ws, method, params = {}) {
const id = msgId++
return new Promise((resolve, reject) => {
const handler = (data) => {
const msg = JSON.parse(data.toString())
if (msg.id === id) {
ws.off('message', handler)
msg.error ? reject(new Error(JSON.stringify(msg.error))) : resolve(msg.result)
}
}
ws.on('message', handler)
ws.send(JSON.stringify({ id, method, params }))
})
}
async function eval_(ws, expr) {
const r = await send(ws, 'Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true })
return r?.result?.value
}
async function sleep(ms) { return new Promise(r => setTimeout(r, ms)) }
async function measure(ws, label, fn, settleMs = 400) {
await eval_(ws, `__RENDER_COUNTS__.start(); true`)
await fn(ws)
await sleep(settleMs)
const report = await eval_(ws, `JSON.stringify(__RENDER_COUNTS__.report())`)
const parsed = JSON.parse(report)
const totalRenders = parsed.reduce((s, c) => s + c.renders, 0)
const totalWasted = parsed.reduce((s, c) => s + c.wasted, 0)
const totalMs = parsed.reduce((s, c) => s + c.totalMs, 0)
const top5 = parsed.filter(c => c.wasted > 0).sort((a,b) => b.wasted - a.wasted).slice(0, 5)
console.log(`\n${label}`)
console.log(` total: ${totalRenders} renders, ${totalWasted} wasted, ${totalMs.toFixed(1)}ms`)
if (top5.length > 0) {
for (const c of top5) {
console.log(` ${c.name}: ${c.renders} renders, ${c.wasted} wasted, ${c.totalMs.toFixed(1)}ms`)
}
} else {
console.log(` (no wasted renders)`)
}
return { label, totalRenders, totalWasted, totalMs, top: top5 }
}
async function clickByText(ws, text, tag = 'button') {
return eval_(ws, `
const el = Array.from(document.querySelectorAll('${tag}')).find(b => b.textContent?.trim() === '${text}')
if (el) { el.click(); true } else false
`)
}
async function clickByHref(ws, partial) {
return eval_(ws, `
const el = Array.from(document.querySelectorAll('a,button')).find(e => e.getAttribute('href')?.includes('${partial}'))
if (el) { el.click(); true } else false
`)
}
async function typeInInput(ws, text) {
for (const ch of text) {
await eval_(ws, `
const el = document.querySelector('[cmdk-input]') || document.querySelector('input[type="text"]') || document.querySelector('input')
if (el) { el.focus(); el.value = el.value + '${ch}'; el.dispatchEvent(new Event('input', {bubbles:true})) }
true
`)
await sleep(50)
}
}
async function pressKey(ws, key, mods = {}) {
await eval_(ws, `
document.dispatchEvent(new KeyboardEvent('keydown', { key: '${key}', ${Object.entries(mods).map(([k,v]) => `${k}:${v}`).join(', ')}, bubbles: true }))
true
`)
}
async function main() {
const ws = new WebSocket(WS_URL)
await new Promise((resolve, reject) => {
ws.on('open', resolve)
ws.on('error', reject)
})
await send(ws, 'Runtime.enable')
const avail = await eval_(ws, `typeof __RENDER_COUNTS__`)
console.log(`__RENDER_COUNTS__: ${avail}`)
if (avail !== 'object') {
console.error('Render counter not loaded. Reload the page.')
process.exit(1)
}
const results = []
// === 1. CmdK root: open, type "setting", close ===
results.push(await measure(ws, '1. CmdK root (type "setting")', async (ws) => {
await pressKey(ws, 'k', { metaKey: true })
await sleep(300)
await typeInInput(ws, 'setting')
}))
await pressKey(ws, 'Escape')
await sleep(300)
// === 2. CmdK submenu: open, navigate to theme picker, click a theme, back ===
results.push(await measure(ws, '2. CmdK theme submenu', async (ws) => {
await pressKey(ws, 'k', { metaKey: true })
await sleep(300)
await typeInInput(ws, 'theme')
await sleep(200)
// Click first theme item
await eval_(ws, `
const item = document.querySelector('[cmdk-item]')
if (item) item.click()
true
`)
await sleep(300)
}))
await pressKey(ws, 'Escape')
await sleep(300)
// === 3. CmdK color-mode submenu ===
results.push(await measure(ws, '3. CmdK color-mode submenu', async (ws) => {
await pressKey(ws, 'k', { metaKey: true })
await sleep(300)
await typeInInput(ws, 'color mode')
await sleep(200)
await eval_(ws, `const item = document.querySelector('[cmdk-item]'); if (item) item.click(); true`)
await sleep(300)
}))
await pressKey(ws, 'Escape')
await sleep(300)
// === 4. Settings: open, click through nav items ===
results.push(await measure(ws, '4. Settings (5 nav clicks)', async (ws) => {
await clickByHref(ws, 'settings')
await sleep(500)
for (const label of ['Appearance', 'Gateway', 'Keys', 'Notifications', 'About']) {
await clickByText(ws, label)
await sleep(100)
}
}))
// === 5. Command Center: open to System tab, idle 2s ===
results.push(await measure(ws, '5. Command Center (System tab, 2s idle)', async (ws) => {
await clickByHref(ws, 'command-center')
await sleep(500)
await clickByText(ws, 'System')
await sleep(2000)
}, 100))
// === 6. Command Center: Sessions tab ===
results.push(await measure(ws, '6. Command Center (Sessions tab, 1s)', async (ws) => {
await clickByText(ws, 'Sessions')
await sleep(1000)
}, 100))
// === 7. Capabilities/Skills page ===
results.push(await measure(ws, '7. Capabilities (Skills tab, 1s)', async (ws) => {
await clickByHref(ws, 'skills')
await sleep(800)
// Click Skills tab if not already
await clickByText(ws, 'Skills')
await sleep(1000)
}, 100))
// === 8. Capabilities Toolsets tab ===
results.push(await measure(ws, '8. Capabilities (Toolsets tab, 1s)', async (ws) => {
await clickByText(ws, 'Tools')
await sleep(1000)
}, 100))
// === 9. Capabilities MCP tab ===
results.push(await measure(ws, '9. Capabilities (MCP tab, 1s)', async (ws) => {
await clickByText(ws, 'MCP')
await sleep(1000)
}, 100))
// Navigate back to chat
await eval_(ws, `window.location.hash = '#/'; true`)
await sleep(300)
// Summary table
console.log('\n\n=== SUMMARY ===')
console.log('Surface'.padEnd(45) + 'Renders'.padStart(10) + 'Wasted'.padStart(10) + 'ms'.padStart(10))
console.log('-'.repeat(75))
for (const r of results) {
console.log(r.label.padEnd(45) + String(r.totalRenders).padStart(10) + String(r.totalWasted).padStart(10) + r.totalMs.toFixed(1).padStart(10))
}
ws.close()
}
main().catch(err => { console.error(err); process.exit(1) })
+110
View File
@@ -0,0 +1,110 @@
#!/usr/bin/env node
// Full surface sweep: every overlay, every settings sub-page, every system overlay.
// Read-only — navigates and clicks, never closes the app.
import WebSocket from 'ws'
const WS_URL = process.env.CDP_WS || 'ws://127.0.0.1:9222/devtools/page/6E095DBE024BD280C674D00023C01201'
let msgId = 1
function send(ws, method, params = {}) {
const id = msgId++
return new Promise((resolve, reject) => {
const handler = (data) => {
const msg = JSON.parse(data.toString())
if (msg.id === id) {
ws.off('message', handler)
msg.error ? reject(new Error(JSON.stringify(msg.error))) : resolve(msg.result)
}
}
ws.on('message', handler)
ws.send(JSON.stringify({ id, method, params }))
})
}
async function eval_(ws, expr) {
const r = await send(ws, 'Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true })
return r?.result?.value
}
async function sleep(ms) { return new Promise(r => setTimeout(r, ms)) }
async function measure(ws, label, fn, settleMs = 300) {
await eval_(ws, `__RENDER_COUNTS__.start(); true`)
await fn(ws)
await sleep(settleMs)
const report = JSON.parse(await eval_(ws, `JSON.stringify(__RENDER_COUNTS__.report())`))
const totalRenders = report.reduce((s, c) => s + c.renders, 0)
const totalWasted = report.reduce((s, c) => s + c.wasted, 0)
const totalMs = report.reduce((s, c) => s + c.totalMs, 0)
const top = report.filter(c => c.wasted > 0).sort((a,b) => b.wasted - a.wasted).slice(0, 6)
console.log(`\n${label}`)
console.log(` ${totalRenders} renders · ${totalWasted} wasted · ${totalMs.toFixed(1)}ms`)
for (const c of top) console.log(` ${c.name}: ${c.renders}r / ${c.wasted}w / ${c.totalMs.toFixed(1)}ms`)
if (top.length === 0) console.log(` (clean)`)
return { label, totalRenders, totalWasted, totalMs }
}
async function nav(ws, hash) {
await eval_(ws, `window.location.hash = '#${hash}'; true`)
}
async function clickText(ws, text, tag='button') {
return eval_(ws, `
const el = Array.from(document.querySelectorAll('${tag}')).find(b => b.textContent?.trim() === ${JSON.stringify(text)})
if (el) { el.click(); true } else false
`)
}
async function main() {
const ws = new WebSocket(WS_URL)
await new Promise((res, rej) => { ws.on('open', res); ws.on('error', rej) })
await send(ws, 'Runtime.enable')
if (await eval_(ws, `typeof __RENDER_COUNTS__`) !== 'object') { console.error('counter not loaded'); process.exit(1) }
const results = []
// System overlays — open each, idle 2s (streaming underneath is the churn test)
for (const [label, route] of [
['Artifacts', '/artifacts'],
['Messaging', '/messaging'],
['Cron', '/cron'],
['Profiles', '/profiles'],
['Agents', '/agents'],
['Starmap', '/starmap'],
['Webhooks', '/webhooks'],
]) {
results.push(await measure(ws, `OVERLAY: ${label} (open + 2s idle)`, async (ws) => {
await nav(ws, route)
await sleep(2200)
}, 100))
await nav(ws, '/')
await sleep(300)
}
// Settings sub-pages — open settings, click each nav item, idle 1.5s on it
await nav(ws, '/settings')
await sleep(600)
for (const label of ['Model','Session','Appearance','Notifications','Providers','Gateway','Keybinds','API Keys','Plugins','Archived Chats','About']) {
results.push(await measure(ws, `SETTINGS: ${label} (1.5s idle)`, async (ws) => {
await clickText(ws, label)
await sleep(1500)
}, 100))
}
await nav(ws, '/')
await sleep(300)
// Messaging sub-tabs (platform detail) — click through if present
await nav(ws, '/messaging')
await sleep(800)
results.push(await measure(ws, 'MESSAGING: idle 2s w/ platform list', async (ws) => {
await sleep(2000)
}, 100))
await nav(ws, '/')
await sleep(300)
console.log('\n\n=== SUMMARY (wasted renders) ===')
console.log('Surface'.padEnd(48) + 'Renders'.padStart(9) + 'Wasted'.padStart(9) + 'ms'.padStart(9))
console.log('-'.repeat(75))
for (const r of results) {
console.log(r.label.padEnd(48) + String(r.totalRenders).padStart(9) + String(r.totalWasted).padStart(9) + r.totalMs.toFixed(1).padStart(9))
}
ws.close()
}
main().catch(e => { console.error(e); process.exit(1) })
+149
View File
@@ -0,0 +1,149 @@
// The real-app perf loop: drive HER hgui instance (real profile, real
// sessions) through the three interactions that matter — session switch,
// sidebar drag, composer typing — and report honest single-clock numbers.
//
// node scripts/diag-real-loop.mjs [--port 9222] [--switches 6]
//
// Unlike the synthetic scenarios this clicks REAL sidebar rows, so session
// switching is measured as the user feels it: click -> transcript painted.
import { attach } from './perf/lib/launch.mjs'
import { sleep } from './perf/lib/cdp.mjs'
const arg = (name, fallback) => {
const i = process.argv.indexOf(`--${name}`)
return i === -1 ? fallback : process.argv[i + 1]
}
const port = Number(arg('port', 9222))
const SWITCHES = Number(arg('switches', 6))
const { cdp, teardown } = await attach({ port })
// ---------------------------------------------------------------------------
// Session switch: click a sidebar session row, await the transcript settling.
// Measures click -> first paint of the new transcript AND click -> settled
// (two rAFs with no further DOM mutation in the thread viewport).
// ---------------------------------------------------------------------------
const SWITCH = swaps => `
(async () => {
const rows = [...document.querySelectorAll('[data-slot="row-button"]')]
.filter(el => el.offsetParent && (el.textContent ?? '').trim())
if (rows.length < 2) return JSON.stringify({ error: 'need 2+ visible session rows, found ' + rows.length })
const results = []
for (let i = 0; i < ${swaps}; i++) {
const row = rows[i % Math.min(rows.length, 4)]
const viewport = () => document.querySelector('[data-slot="aui_thread-viewport"]')
const t0 = performance.now()
row.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, cancelable: true, pointerId: 1, isPrimary: true, button: 0, buttons: 1 }))
row.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, cancelable: true, pointerId: 1, isPrimary: true, button: 0 }))
row.click()
// First paint: next two rAFs after the click.
await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)))
const firstPaint = performance.now() - t0
// Settled: no mutations in the viewport for 2 consecutive frames, capped 3s.
let lastMutation = performance.now()
const target = viewport() ?? document.body
const mo = new MutationObserver(() => { lastMutation = performance.now() })
mo.observe(target, { childList: true, subtree: true, characterData: true })
const deadline = performance.now() + 3000
while (performance.now() < deadline) {
await new Promise(r => requestAnimationFrame(r))
if (performance.now() - lastMutation > 120) break
}
mo.disconnect()
results.push({ firstPaint: Math.round(firstPaint), settled: Math.round(performance.now() - t0 - 120) })
await new Promise(r => setTimeout(r, 250))
}
return JSON.stringify(results)
})()
`
// ---------------------------------------------------------------------------
// Drag the first visible sash, single-clock frames.
// ---------------------------------------------------------------------------
const DRAG = `
(async () => {
const handle = [...document.querySelectorAll('[role="separator"]')].find(el => el.offsetParent || el.getBoundingClientRect().width > 0)
if (!handle) return JSON.stringify({ error: 'no sash' })
const box = handle.getBoundingClientRect()
const y = box.top + box.height / 2
const x0 = box.left + box.width / 2
let x = x0
const o = { bubbles: true, cancelable: true, pointerId: 1, pointerType: 'mouse', isPrimary: true, button: 0, buttons: 1 }
const frames = []
let last = performance.now()
handle.dispatchEvent(new PointerEvent('pointerdown', { ...o, clientX: x, clientY: y }))
for (let i = 0; i < 60; i++) {
x += (i < 30 ? 2 : -2)
window.dispatchEvent(new PointerEvent('pointermove', { ...o, clientX: x, clientY: y }))
await new Promise(r => requestAnimationFrame(r))
const now = performance.now(); frames.push(now - last); last = now
}
window.dispatchEvent(new PointerEvent('pointerup', { ...o, buttons: 0, clientX: x, clientY: y }))
const total = frames.reduce((a, b) => a + b, 0)
const sorted = [...frames].sort((a, b) => a - b)
return JSON.stringify({
fps: Math.round((frames.length / total) * 1000 * 10) / 10,
p95: Math.round(sorted[Math.floor(sorted.length * 0.95)] * 10) / 10,
worst: Math.round(sorted[sorted.length - 1] * 10) / 10,
slow33: frames.filter(f => f > 33).length
})
})()
`
// ---------------------------------------------------------------------------
// Type into the composer, single-clock frames (one mark per keystroke frame).
// ---------------------------------------------------------------------------
const TYPE = `
(async () => {
const el = [...document.querySelectorAll('[contenteditable="true"]')].find(e => e.offsetParent)
if (!el) return JSON.stringify({ error: 'no composer' })
el.focus()
const frames = []
let last = performance.now()
for (let i = 0; i < 40; i++) {
const ch = 'the quick brown fox '[i % 20]
el.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: ch }))
el.textContent += ch
el.dispatchEvent(new InputEvent('input', { bubbles: true, data: ch, inputType: 'insertText' }))
el.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, key: ch }))
await new Promise(r => requestAnimationFrame(r))
const now = performance.now(); frames.push(now - last); last = now
await new Promise(r => setTimeout(r, 20))
}
// Clear what we typed.
el.textContent = ''
el.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'deleteContentBackward' }))
const moving = frames
const total = moving.reduce((a, b) => a + b, 0)
const sorted = [...moving].sort((a, b) => a - b)
return JSON.stringify({
fps: Math.round((moving.length / total) * 1000 * 10) / 10,
p95: Math.round(sorted[Math.floor(sorted.length * 0.95)] * 10) / 10,
worst: Math.round(sorted[sorted.length - 1] * 10) / 10,
slow33: moving.filter(f => f > 33).length
})
})()
`
try {
await cdp.send('Runtime.enable')
console.log('== SESSION SWITCH (click -> paint / settled ms) ==')
console.log(await cdp.eval(SWITCH(SWITCHES)))
await sleep(500)
console.log('\n== SIDEBAR DRAG ==')
console.log(await cdp.eval(DRAG))
await sleep(500)
console.log('\n== COMPOSER TYPING ==')
console.log(await cdp.eval(TYPE))
} finally {
teardown?.()
}
+170
View File
@@ -0,0 +1,170 @@
// How many ResizeObserver callbacks does one sash drag actually fire, and for
// how many DISTINCT elements? The trace named use-resize-observer.ts at 977ms
// but not whether that's a few expensive calls or a great many cheap ones —
// and the fix differs completely between those.
//
// node scripts/diag-ro-storm.mjs [--port 9222] [--tiles 5]
import { attach } from './perf/lib/launch.mjs'
import { sleep } from './perf/lib/cdp.mjs'
const arg = (name, fallback) => {
const i = process.argv.indexOf(`--${name}`)
return i === -1 ? fallback : process.argv[i + 1]
}
const port = Number(arg('port', 9222))
const TILES = Number(arg('tiles', 5))
const TURNS = Number(arg('turns', 20))
const setup = `
(() => {
const hook = window.__HERMES_SESSION_TILES__
if (!hook) return 'no-hook'
const turn = (sid, i) => ([
{ id: sid + '-u' + i, role: 'user', timestamp: Date.now(),
parts: [{ type: 'text', text: 'Question ' + i + ' about the diff and its error path.' }] },
{ id: sid + '-a' + i, role: 'assistant', timestamp: Date.now(), pending: false,
parts: [{ type: 'text', text: '## Finding ' + i + '\\n\\nProse with **bold** and \`code\`.\\n' }] }
])
window.__R__ = { ids: [] }
for (let n = 1; n <= ${TILES}; n++) {
const sid = 'ro-tile-' + n
const rid = 'ro-rt-' + n
const messages = []
for (let i = 0; i < ${TURNS}; i++) messages.push(...turn(sid, i))
messages.push({ id: sid + '-stream', role: 'assistant', timestamp: Date.now(), pending: true,
parts: [{ type: 'text', text: 'Working.' }] })
window.__R__.ids.push({ sid, rid })
hook.open(sid, 'center')
hook.patch(sid, { runtimeId: rid })
hook.publish(rid, {
storedSessionId: sid, messages, branch: '', cwd: '', model: '', provider: '',
reasoningEffort: '', serviceTier: '', fast: false, yolo: false, personality: '',
busy: true, awaitingResponse: false, streamId: sid + '-stream', sawAssistantPayload: true,
pendingBranchGroup: null, interrupted: false, interimBoundaryPending: false,
needsInput: false, turnStartedAt: Date.now(), usage: null
})
}
return 'ok'
})()
`
const reveal = sid => `window.__HERMES_LAYOUT_TREE__.reveal(${JSON.stringify(`session-tile:${sid}`)})`
// Patch ResizeObserver to count callbacks + distinct observed targets, then
// drag and report. Counting happens in the page so nothing crosses CDP per call.
//
// NOTE: the app's shared observer (hooks/use-resize-observer.ts) is created
// lazily on first use, so this patch must be installed BEFORE any surface
// mounts — otherwise the shared instance is a native one this wrapper never
// sees and every counter reads zero. `constructed` is the tell: a run showing
// a handful of constructions and zero callbacks means the patch landed late,
// not that the app stopped observing.
const INSTRUMENT = `
(() => {
if (window.__ROSTATS__) return 'already'
const Native = window.ResizeObserver
const stats = { constructed: 0, observed: 0, callbacks: 0, entries: 0, targets: new Set(), on: false }
window.__ROSTATS__ = stats
window.ResizeObserver = class extends Native {
constructor(cb) {
super((entries, obs) => {
if (stats.on) {
stats.callbacks += 1
stats.entries += entries.length
for (const e of entries) stats.targets.add(e.target)
}
return cb(entries, obs)
})
stats.constructed += 1
}
observe(...args) {
stats.observed += 1
return super.observe(...args)
}
}
return 'patched'
})()
`
const DRAG = `
(async () => {
const s = window.__ROSTATS__
s.callbacks = 0; s.entries = 0; s.targets = new Set(); s.on = true
const handle = document.querySelector('[role="separator"]')
if (!handle) { s.on = false; return JSON.stringify({ error: 'no sash' }) }
const box = handle.getBoundingClientRect()
const y = box.top + box.height / 2
const x0 = box.left + box.width / 2
let x = x0
const opts = { bubbles: true, cancelable: true, pointerId: 1, pointerType: 'mouse', isPrimary: true, button: 0, buttons: 1 }
const t0 = performance.now()
handle.dispatchEvent(new PointerEvent('pointerdown', { ...opts, clientX: x, clientY: y }))
for (let i = 0; i < 40; i++) {
x += (i < 20 ? 3 : -3)
window.dispatchEvent(new PointerEvent('pointermove', { ...opts, clientX: x, clientY: y }))
await new Promise(r => setTimeout(r, 16))
}
window.dispatchEvent(new PointerEvent('pointerup', { ...opts, buttons: 0, clientX: x, clientY: y }))
await new Promise(r => setTimeout(r, 300))
s.on = false
return JSON.stringify({
ms: Math.round(performance.now() - t0),
moves: 40,
constructed: s.constructed,
observed: s.observed,
callbacks: s.callbacks,
entries: s.entries,
distinctTargets: s.targets.size,
userBubbles: document.querySelectorAll('[data-slot="aui_user-message-root"]').length
})
})()
`
const CLEANUP = `
(() => {
if (window.__R__) {
for (const { sid, rid } of window.__R__.ids) {
const s = window.__HERMES_SESSION_TILES__.states()
window.__HERMES_SESSION_TILES__.publish(rid, { ...s[rid], busy: false, streamId: null })
window.__HERMES_SESSION_TILES__.close(sid)
}
window.__R__ = null
}
return 'cleaned'
})()
`
const { cdp, teardown } = await attach({ port })
try {
await cdp.send('Runtime.enable')
await cdp.eval(INSTRUMENT)
const ok = await cdp.eval(setup)
if (ok !== 'ok') {
throw new Error(`setup failed: ${ok}`)
}
for (let n = 1; n <= TILES; n++) {
await cdp.eval(reveal(`ro-tile-${n}`))
await sleep(300)
}
await sleep(1500)
const r = JSON.parse(await cdp.eval(DRAG))
await cdp.eval(CLEANUP)
console.log(JSON.stringify(r, null, 2))
if (r.moves) {
console.log(`\nper pointermove: ${(r.entries / r.moves).toFixed(1)} RO entries`)
console.log(`distinct elements resized: ${r.distinctTargets} (user bubbles in DOM: ${r.userBubbles})`)
}
} finally {
teardown?.()
}
+229
View File
@@ -0,0 +1,229 @@
// Reproduce + diagnose the "scroll wheel resets position while reading" bug.
//
// The complaint (Windows, mouse wheel): scrolling UP through a chat to re-read
// older content randomly yanks the view to a different position, so you have to
// fight the scrollbar. Mac users on trackpads don't see it.
//
// Hypothesis: the thread scroller has the browser default `overflow-anchor:
// auto`, and the thread renders items in natural document flow (padding
// spacers, NOT transforms). When an item above the viewport is measured by
// @tanstack/react-virtual (its real height differs a lot from the 220px
// estimate) — or when Shiki/images/fonts reflow it — TWO mechanisms both
// adjust scrollTop for the same delta: TanStack's measurement compensation AND
// the browser's native scroll anchoring. The double-correction lurches the
// view. A mouse wheel's coarse, discrete notches mount/measure several
// under-estimated turns per tick, so the over-correction is large and visible;
// a trackpad's ~1-3px/frame keeps it sub-perceptual.
//
// This script drives synthetic mouse-wheel-UP scrolling on a long thread and
// measures how much a tracked on-screen turn jumps, first with
// `overflow-anchor: auto` (reproduce) then `overflow-anchor: none` (the fix).
// If the fix run shows dramatically fewer/smaller jumps, the hypothesis holds.
//
// Prereq: a running desktop app with remote debugging on 9222, on a thread
// with enough history to scroll (the longer / more code+tool blocks, the
// better the repro). Then: node apps/desktop/scripts/diag-scroll-reset.mjs
const NOTCHES = 14 // wheel-up ticks per sweep
const NOTCH_PX = 120 // Windows wheel notch ≈ 120px
const NOTCH_GAP_MS = 130 // let each smooth-scroll animation settle
const REVERSE_JUMP_PX = 6 // tracked turn moving UP while scrolling up = wrong way
const LURCH_PX = 60 // single-frame on-screen jump that reads as a "reset"
const list = await (await fetch('http://127.0.0.1:9222/json/list')).json()
const tgt = list.find(t => t.type === 'page' && t.url.startsWith('http'))
if (!tgt) {
console.error('No page target on :9222. Is the desktop app running with --remote-debugging-port=9222?')
process.exit(1)
}
const ws = new WebSocket(tgt.webSocketDebuggerUrl)
let id = 0
const pending = new Map()
ws.addEventListener('message', ev => {
const m = JSON.parse(ev.data)
if (m.id != null && pending.has(m.id)) {
pending.get(m.id)(m)
pending.delete(m.id)
}
})
await new Promise(r => ws.addEventListener('open', r))
const send = (m, p = {}) =>
new Promise(r => {
const i = ++id
pending.set(i, r)
ws.send(JSON.stringify({ id: i, method: m, params: p }))
})
const evalP = async expr => {
const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true })
if (r.result?.exceptionDetails) throw new Error(r.result.exceptionDetails.text)
return r.result.result.value
}
const sleep = ms => new Promise(r => setTimeout(r, ms))
// Install per-sweep instrumentation. `mode` is the overflow-anchor value to
// force inline so we A/B the exact same thread regardless of any CSS fix.
// Starts from ~45% down the thread so there's room to scroll up into
// not-yet-measured turns, tags the turn nearest viewport-center as the anchor,
// then records (per rAF) scrollTop + that turn's on-screen top, plus every
// scrollTop *setter* write (TanStack compensation) and ResizeObserver hit.
async function arm(mode) {
await evalP(`(() => {
const v = document.querySelector('[data-slot="aui_thread-viewport"]')
if (!v) throw new Error('thread viewport not found')
// Force the overflow-anchor behavior under test (inline beats CSS).
v.style.overflowAnchor = ${JSON.stringify(mode)}
// Park ~45% down so a wheel-up sweep climbs into estimated-but-unmeasured
// turns above the fold (where the measurement correction fires).
v.scrollTop = Math.round(v.scrollHeight * 0.45)
// Tag the turn closest to viewport center; we track its on-screen top.
const vr = v.getBoundingClientRect()
const center = vr.top + v.clientHeight / 2
let best = null, bestD = Infinity
for (const el of v.querySelectorAll('[data-index]')) {
const r = el.getBoundingClientRect()
const d = Math.abs((r.top + r.height / 2) - center)
if (d < bestD) { bestD = d; best = el }
}
document.querySelectorAll('[data-se-anchor]').forEach(e => e.removeAttribute('data-se-anchor'))
if (best) best.setAttribute('data-se-anchor', '1')
const anchorIndex = best ? best.getAttribute('data-index') : null
const samples = []
const writes = []
const ros = []
const t0 = performance.now()
// Intercept scrollTop writes → these are JS (TanStack) corrections.
// Native browser scroll anchoring does NOT go through this setter, so a
// scrollTop change with no write in the same frame is a native adjust.
const desc = Object.getOwnPropertyDescriptor(Element.prototype, 'scrollTop')
Object.defineProperty(v, 'scrollTop', {
configurable: true,
get() { return desc.get.call(this) },
set(val) {
writes.push({ t: performance.now() - t0, val, sh: this.scrollHeight })
desc.set.call(this, val)
}
})
window.__restoreScrollTop = () => Object.defineProperty(v, 'scrollTop', desc)
const ro = new ResizeObserver(entries => {
for (const e of entries) {
ros.push({ t: performance.now() - t0, slot: e.target.getAttribute?.('data-slot') || e.target.tagName, h: Math.round(e.contentRect.height) })
}
})
ro.observe(v)
if (v.firstElementChild) ro.observe(v.firstElementChild)
let running = true
const tick = () => {
if (!running) return
const a = v.querySelector('[data-se-anchor]')
const ar = a ? a.getBoundingClientRect() : null
samples.push({
t: performance.now() - t0,
st: Math.round(v.scrollTop * 100) / 100,
sh: v.scrollHeight,
ch: v.clientHeight,
atop: ar ? Math.round(ar.top * 100) / 100 : null,
aconn: !!a
})
requestAnimationFrame(tick)
}
requestAnimationFrame(tick)
window.__se = { samples, writes, ros, anchorIndex, dpr: window.devicePixelRatio, stop() { running = false; ro.disconnect(); window.__restoreScrollTop?.() } }
return true
})()`)
}
async function wheelUpSweep() {
const { x, y } = await evalP(`(() => {
const v = document.querySelector('[data-slot="aui_thread-viewport"]')
const r = v.getBoundingClientRect()
return { x: Math.round(r.left + r.width / 2), y: Math.round(r.top + r.height / 2) }
})()`)
for (let i = 0; i < NOTCHES; i++) {
await send('Input.dispatchMouseEvent', { type: 'mouseWheel', x, y, deltaX: 0, deltaY: -NOTCH_PX })
await sleep(NOTCH_GAP_MS)
}
await sleep(400)
}
async function collect() {
const data = JSON.parse(await evalP(`(() => { window.__se.stop(); return JSON.stringify(window.__se) })()`))
return data
}
function analyze(label, data) {
const { samples, writes, ros, anchorIndex, dpr } = data
let reverseJumps = 0
let reverseSum = 0
let lurches = 0
let maxJump = 0
let nativeMoves = 0
let prev = null
for (const s of samples) {
if (prev && prev.aconn && s.aconn && prev.atop != null && s.atop != null) {
const dTop = s.atop - prev.atop // wheel-up should move content DOWN → dTop >= 0
const dSt = s.st - prev.st
// Native (browser-anchoring) move: scrollTop changed with no setter write in this frame window.
const wroteThisFrame = writes.some(w => w.t > prev.t && w.t <= s.t)
if (Math.abs(dSt) > 0.5 && !wroteThisFrame) nativeMoves++
if (dTop < -REVERSE_JUMP_PX) {
reverseJumps++
reverseSum += -dTop
}
if (Math.abs(dTop) > LURCH_PX) lurches++
if (Math.abs(dTop) > maxJump) maxJump = Math.abs(dTop)
}
prev = s
}
console.log(`\n── ${label} ──`)
console.log(` devicePixelRatio: ${dpr}${Number.isInteger(dpr) ? '' : ' (fractional — Windows scaling, worsens rounding jitter)'}`)
console.log(` tracked turn index: ${anchorIndex}`)
console.log(` rAF frames: ${samples.length}`)
console.log(` scrollTop writes: ${writes.length} (TanStack measurement corrections)`)
console.log(` ResizeObserver hits: ${ros.length}`)
console.log(` native scroll moves: ${nativeMoves} (scrollTop moved with NO JS write = browser anchoring)`)
console.log(` reverse jumps: ${reverseJumps} (tracked turn yanked UP while scrolling up; total ${reverseSum.toFixed(0)}px)`)
console.log(` big lurches (>${LURCH_PX}px): ${lurches}`)
console.log(` max single-frame jump: ${maxJump.toFixed(0)}px`)
return { reverseJumps, reverseSum, lurches, maxJump, nativeMoves }
}
console.log(`Wheel-up repro: ${NOTCHES} notches × ${NOTCH_PX}px, anchored mid-thread.\n`)
await arm('auto')
await sleep(150)
await wheelUpSweep()
const a = analyze('overflow-anchor: auto (current / repro)', await collect())
await sleep(300)
await arm('none')
await sleep(150)
await wheelUpSweep()
const b = analyze('overflow-anchor: none (proposed fix)', await collect())
// Clean up our tag.
await evalP(`document.querySelectorAll('[data-se-anchor]').forEach(e => e.removeAttribute('data-se-anchor'))`)
console.log('\n══ verdict ══')
const drop = (x, y) => (x === 0 ? (y === 0 ? '0' : 'n/a') : `${Math.round((1 - y / x) * 100)}% fewer`)
console.log(` reverse jumps: auto=${a.reverseJumps} none=${b.reverseJumps} (${drop(a.reverseJumps, b.reverseJumps)})`)
console.log(` big lurches: auto=${a.lurches} none=${b.lurches} (${drop(a.lurches, b.lurches)})`)
console.log(` max jump: auto=${a.maxJump.toFixed(0)}px none=${b.maxJump.toFixed(0)}px`)
console.log(` native moves: auto=${a.nativeMoves} none=${b.nativeMoves} (browser anchoring should ~vanish at none)`)
if (a.reverseJumps + a.lurches > 0 && b.reverseJumps + b.lurches < a.reverseJumps + a.lurches) {
console.log('\n → Jumps drop sharply with overflow-anchor:none → root cause confirmed.')
} else if (a.reverseJumps + a.lurches === 0) {
console.log('\n → No jumps captured this run. Use a longer thread (many code/tool blocks),')
console.log(' raise NOTCHES, and ensure you start scrolled up from the bottom.')
}
ws.close()
+27
View File
@@ -0,0 +1,27 @@
// Dump the sidebar's actual DOM shape so selectors stop being guesses.
import { attach } from './perf/lib/launch.mjs'
const { cdp, teardown } = await attach({ port: 9222 })
try {
await cdp.send('Runtime.enable')
const out = await cdp.eval(`(() => {
const sidebar = document.querySelector('[data-slot="sidebar"]') ?? document.querySelector('aside')
if (!sidebar) return '(no sidebar el)'
// Find clickable rows: anchors or buttons with text, depth-limited sample.
const clickables = [...sidebar.querySelectorAll('a, button, [role="button"], [data-slot]')].slice(0, 60)
const rows = clickables.map(el => ({
tag: el.tagName.toLowerCase(),
slot: el.getAttribute('data-slot') ?? '',
cls: (el.className?.baseVal ?? el.className ?? '').toString().slice(0, 40),
text: (el.textContent ?? '').trim().slice(0, 30),
visible: !!el.offsetParent
})).filter(r => r.text)
return JSON.stringify(rows.slice(0, 30), null, 1)
})()`)
console.log(out)
} finally {
teardown?.()
}
@@ -0,0 +1,52 @@
// Session-switch autopsy: click between the two heaviest rows repeatedly,
// recording per-switch (a) settled ms, (b) React commits, (c) top rendered
// components — so slow switches name themselves.
import { attach } from './perf/lib/launch.mjs'
import { sleep } from './perf/lib/cdp.mjs'
const arg = (name, fallback) => {
const i = process.argv.indexOf(`--${name}`)
return i === -1 ? fallback : process.argv[i + 1]
}
const port = Number(arg('port', 9222))
const ROUNDS = Number(arg('rounds', 8))
const { cdp, teardown } = await attach({ port })
const SWITCH_ONE = index => `
(async () => {
const rows = [...document.querySelectorAll('[data-slot="row-button"]')].filter(el => el.offsetParent)
if (rows.length < 2) return JSON.stringify({ error: 'rows' })
const row = rows[${index} % 2]
const rc = window.__RENDER_COUNTS__
rc.start()
const t0 = performance.now()
row.click()
let lastMutation = performance.now()
const mo = new MutationObserver(() => { lastMutation = performance.now() })
mo.observe(document.body, { childList: true, subtree: true, characterData: true })
const deadline = performance.now() + 4000
while (performance.now() < deadline) {
await new Promise(r => requestAnimationFrame(r))
if (performance.now() - lastMutation > 150) break
}
mo.disconnect()
rc.stop()
const settled = Math.round(performance.now() - t0 - 150)
const report = rc.report(6).map(r => r.name + ':' + r.renders + '(' + Math.round(r.totalMs) + 'ms)')
return JSON.stringify({ label: (row.textContent ?? '').slice(0, 24), settled, commits: rc.commits(), top: report })
})()
`
try {
await cdp.send('Runtime.enable')
for (let i = 0; i < ROUNDS; i++) {
console.log(await cdp.eval(SWITCH_ONE(i)))
await sleep(400)
}
} finally {
teardown?.()
}
@@ -0,0 +1,74 @@
// What happens during a SLOW session switch? Click a heavy row with tracing
// on, dump the style/layout/script split plus top callsites.
import { attach } from './perf/lib/launch.mjs'
import { sleep } from './perf/lib/cdp.mjs'
const { cdp, teardown } = await attach({ port: 9222 })
const CLICK_HEAVIEST = `
(() => {
const rows = [...document.querySelectorAll('[data-slot="row-button"]')].filter(el => el.offsetParent)
if (rows.length < 2) return 'need rows'
// Alternate between the first two rows so every run actually switches.
const current = location.hash
const target = rows.find(r => !r.getAttribute('data-active')) ?? rows[1]
target.click()
return 'clicked: ' + (target.textContent ?? '').slice(0, 40)
})()
`
const events = []
let complete = false
cdp.on('Tracing.dataCollected', p => events.push(...(p.value ?? [])))
cdp.on('Tracing.tracingComplete', () => {
complete = true
})
try {
await cdp.send('Runtime.enable')
await cdp.send('Tracing.start', {
transferMode: 'ReportEvents',
traceConfig: { includedCategories: ['devtools.timeline'] }
})
console.log(await cdp.eval(CLICK_HEAVIEST))
await sleep(2500)
console.log(await cdp.eval(CLICK_HEAVIEST))
await sleep(2500)
await cdp.send('Tracing.end')
for (let w = 0; !complete && w < 10000; w += 200) {
await sleep(200)
}
const totals = new Map()
const byFn = new Map()
for (const e of events) {
if (e.ph !== 'X' || typeof e.dur !== 'number') {
continue
}
totals.set(e.name, (totals.get(e.name) ?? 0) + e.dur / 1000)
if (e.name === 'FunctionCall') {
const d = e.args?.data ?? {}
const key = `${d.functionName || '(anon)'} @ ${(d.url || '?').split('/').pop()}:${d.lineNumber ?? '?'}`
byFn.set(key, (byFn.get(key) ?? 0) + e.dur / 1000)
}
}
const style = totals.get('UpdateLayoutTree') ?? 0
const layout = totals.get('Layout') ?? 0
const script = (totals.get('FunctionCall') ?? 0) + (totals.get('EvaluateScript') ?? 0) + (totals.get('TimerFire') ?? 0)
console.log(`\nVERDICT over 2 switches: style=${style.toFixed(0)}ms layout=${layout.toFixed(0)}ms script=${script.toFixed(0)}ms paint=${(totals.get('Paint') ?? 0).toFixed(0)}ms`)
console.log('\nTOP CALLSITES:')
for (const [name, ms] of [...byFn.entries()].sort((a, b) => b[1] - a[1]).slice(0, 12)) {
console.log(` ${ms.toFixed(1).padStart(8)} ${name}`)
}
} finally {
teardown?.()
}
+46
View File
@@ -0,0 +1,46 @@
// Simple eval helper — runs an expression and returns the result.value.
//
// node scripts/eval.mjs "document.title"
// HERMES_DESKTOP_CDP_PORT=9333 node scripts/eval.mjs "document.title"
//
// Needs a renderer with a debugging port: launch `hgui` / `npm run dev` with
// HERMES_DESKTOP_CDP_PORT set (see electron/dev-cdp.ts).
const port = Number(process.env.HERMES_DESKTOP_CDP_PORT || 9222)
let targets
try {
targets = await (await fetch(`http://127.0.0.1:${port}/json`)).json()
} catch {
console.error(
`no renderer debugging port on 127.0.0.1:${port}. ` +
'Dev-server runs (`npm run dev` / `hgui`) open one automatically — check the app is running, ' +
'and that HERMES_DESKTOP_CDP_PORT is not set to "off" or another port.'
)
process.exit(1)
}
const t = targets.find((t) => t.url.includes('5174')) ?? targets.find((t) => t.type === 'page')
if (!t) {
console.error(`no page target on 127.0.0.1:${port} (found ${targets.length} target(s))`)
process.exit(1)
}
const ws = new WebSocket(t.webSocketDebuggerUrl)
let id = 0
const pending = new Map()
ws.addEventListener('message', (ev) => {
const m = JSON.parse(ev.data)
if (pending.has(m.id)) { pending.get(m.id)(m); pending.delete(m.id) }
})
await new Promise((r) => ws.addEventListener('open', r))
const send = (method, params) => new Promise((res) => { const i = ++id; pending.set(i, res); ws.send(JSON.stringify({ id: i, method, params })) })
const expr = process.argv[2] || '1+1'
const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true })
if (r.result.exceptionDetails) {
console.error('EXCEPTION:', r.result.exceptionDetails.exception?.description)
} else {
console.log(JSON.stringify(r.result.result.value, null, 2))
}
ws.close()
+171
View File
@@ -0,0 +1,171 @@
// Throwaway generator: deterministic fake star-map graphs → real share codes
// (runs the actual encoder, so every string round-trips). Run with `npx tsx`.
import { writeFileSync } from 'node:fs'
import type { StarmapEdge, StarmapGraph, StarmapMemoryCard, StarmapNode } from '../src/types/hermes'
import { decodeShareCode, encodeShareCode } from '../src/app/starmap/share-code'
const DAY = 86_400
const END = Math.floor(Date.UTC(2026, 5, 29) / 1000)
// mulberry32 — tiny seeded PRNG so the output is byte-stable across runs.
const rng = (seed: number) => () => {
seed |= 0
seed = (seed + 0x6d2b79f5) | 0
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed)
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
return ((t ^ (t >>> 14)) >>> 0) / 4_294_967_296
}
const pick = <T>(arr: readonly T[], r: number): T => arr[Math.floor(r * arr.length)]!
const CATEGORIES = ['devops', 'research', 'creative', 'security', 'mlops', 'blockchain', 'email', 'health', 'web-development', 'comms'] as const
const STATES = ['active', 'active', 'active', 'archived', 'draft', 'disabled'] as const
const CREATED = [null, 'agent', 'agent', 'user'] as const
const skill = (id: string, label: string, ts: number, r: () => number): StarmapNode => ({
category: pick(CATEGORIES, r()),
createdBy: pick(CREATED, r()),
id,
kind: 'skill',
label,
pinned: r() > 0.85,
state: pick(STATES, r()),
timestamp: ts,
useCount: Math.floor(r() ** 3 * 120)
})
const memNode = (i: number, source: 'memory' | 'profile', label: string, ts: null | number): StarmapNode => ({
category: 'memory',
createdBy: 'memory',
id: `memory:${source}:${i}`,
kind: 'memory',
label,
memorySource: source,
pinned: false,
state: 'active',
timestamp: ts,
useCount: 0
})
const card = (source: 'memory' | 'profile', title: string, body: string, ts: null | number): StarmapMemoryCard => ({ body, source, timestamp: ts, title })
// ── 1. Tiny + quirky ──────────────────────────────────────────────────────────
function tiny(): StarmapGraph {
const r = rng(7)
const nodes: StarmapNode[] = [
skill('summon-coffee', 'Summon Coffee', END - 40 * DAY, r),
skill('rubber-duck', 'Rubber-Duck Debugging', END - 22 * DAY, r),
skill('git-blame-zen', 'Git Blame Without Rage', END - 9 * DAY, r),
memNode(0, 'profile', 'Prefers tabs, dies on this hill', END - 30 * DAY),
memNode(1, 'memory', 'The prod incident of last Tuesday', END - 3 * DAY)
]
const edges: StarmapEdge[] = [
{ source: 'memory:memory:1', target: 'git-blame-zen' },
{ source: 'rubber-duck', target: 'git-blame-zen' }
]
const memory = [
card('profile', 'Prefers tabs, dies on this hill', 'Tabs over spaces. Non-negotiable.', END - 30 * DAY),
card('memory', 'The prod incident of last Tuesday', 'Never deploy on a Friday again.', END - 3 * DAY)
]
return { clusters: [], edges, memory, nodes, stats: {} }
}
// ── 2. Mid-size, mixed signal ────────────────────────────────────────────────
function mid(): StarmapGraph {
const r = rng(42)
const names = ['Kubernetes Whispering', 'Prompt Surgery', 'Threat Modeling', 'Pixel Pushing', 'Vector Janitor', 'Smart-Contract Audit', 'Inbox Zero Ops', 'Sleep Debt Tracker', 'SSR Hydration', 'Standup Telepathy', 'Flaky-Test Exorcism', 'Cost Spelunking']
const nodes: StarmapNode[] = names.map((label, i) => skill(`s${i}`, label, END - Math.floor(r() * 200) * DAY, r))
const memTitles = ['Hates meetings before noon', 'Lives in us-east-1', 'Allergic to YAML', 'Caffeine half-life ~5h', 'Reviews in dark mode']
memTitles.forEach((title, i) => {
const ts = END - Math.floor(r() * 120) * DAY
nodes.push(memNode(i, i % 2 ? 'memory' : 'profile', title, ts))
})
const edges: StarmapEdge[] = []
for (let i = 0; i < 9; i += 1) {
edges.push({ source: `s${Math.floor(r() * names.length)}`, target: `s${Math.floor(r() * names.length)}` })
}
const memory = memTitles.map((title, i) => card(i % 2 ? 'memory' : 'profile', title, `${title}. Logged automatically.`, END - Math.floor(rng(99 + i)() * 120) * DAY))
return { clusters: [], edges, memory, nodes, stats: {} }
}
// ── 3. Dense web, partly undated (ordinal fallback) ──────────────────────────
function web(): StarmapGraph {
const r = rng(1337)
const nodes: StarmapNode[] = Array.from({ length: 22 }, (_, i) =>
// Half the skills carry no timestamp → exercises the ordinal recency path.
skill(`w${i}`, `Neuron ${String.fromCharCode(65 + (i % 26))}${i}`, i % 2 ? END - Math.floor(r() * 300) * DAY : (null as unknown as number), r)
)
const edges: StarmapEdge[] = []
for (let i = 0; i < 44; i += 1) {
edges.push({ source: `w${Math.floor(r() * 22)}`, target: `w${Math.floor(r() * 22)}` })
}
return { clusters: [], edges, memory: [], nodes, stats: {} }
}
// ── 4. The beast: ~2 years, hundreds of nodes, bursty timeline ───────────────
function beast(): StarmapGraph {
const r = rng(2024)
const start = END - 730 * DAY
const span = END - start
const nodes: StarmapNode[] = []
const memory: StarmapMemoryCard[] = []
// Bursts → an interesting waveform instead of a flat smear.
const burstAt = (q: number) => Math.floor(start + (q + (r() - 0.5) * 0.06) * span)
for (let i = 0; i < 240; i += 1) {
const burst = Math.floor(r() ** 1.5 * 12) / 12 // cluster toward the recent end
nodes.push(skill(`b${i}`, `Skill ${i} · ${pick(CATEGORIES, r())}`, burstAt(burst), r))
}
for (let i = 0; i < 150; i += 1) {
const ts = burstAt(Math.floor(r() ** 1.5 * 12) / 12)
const source = r() > 0.5 ? 'memory' : 'profile'
nodes.push(memNode(i, source, `Memory ${i}: ${pick(['quirk', 'fact', 'preference', 'incident', 'lesson'], r())}`, ts))
memory.push(card(source, `Memory ${i}`, `Auto-captured note #${i}.`, ts))
}
const edges: StarmapEdge[] = []
for (let i = 0; i < 380; i += 1) {
const a = Math.floor(r() * 240)
const b = Math.floor(r() * 240)
if (a !== b) {
edges.push({ source: `b${a}`, target: `b${b}` })
}
}
return { clusters: [], edges, memory, nodes, stats: {} }
}
const graphs: [string, StarmapGraph][] = [
['tiny + quirky', tiny()],
['mid · mixed signal', mid()],
['dense web · half undated', web()],
['the beast · ~2 years', beast()]
]
const lines: string[] = []
for (const [name, g] of graphs) {
const code = encodeShareCode(g)
const back = decodeShareCode(code) // round-trip assert — throws if invalid
// v2 is viz-only: nodes + edge topology survive; memory prose is dropped.
const ok = back.nodes.length === g.nodes.length && back.edges.length <= g.edges.length
console.log(`${ok ? 'ok ' : 'BAD'} ${name}${g.nodes.length} nodes / ${g.edges.length} edges / ${g.memory.length} cards (${code.length} chars)`)
lines.push(`# ${name}${g.nodes.length} nodes, ${g.edges.length} edges, ${g.memory.length} cards`, code, '')
}
writeFileSync(new URL('share-codes.txt', import.meta.url), lines.join('\n'))
+290
View File
@@ -0,0 +1,290 @@
// Live-drive harness for the REAL hgui instance on :9222.
//
// node scripts/live-drive.mjs status — targets, session count, perf-live armed?
// node scripts/live-drive.mjs fps [seconds] — raw rAF fps over N seconds (default 4)
// node scripts/live-drive.mjs drag — drag the sidebar sash, report fps + LoAF
// node scripts/live-drive.mjs type — type into composer, report fps + LoAF
// node scripts/live-drive.mjs switch — cycle through sidebar sessions, per-switch ms
// node scripts/live-drive.mjs send "msg" — submit a prompt in the focused session
// node scripts/live-drive.mjs eval "expr" — arbitrary page eval
//
// Attaches to the page target directly (no perf-harness deps) so it works on
// the app Brooklyn actually runs, with her profile, her sessions, her layout.
import { WebSocket } from 'ws'
const PORT = Number(process.env.CDP_PORT ?? 9222)
async function attach() {
const list = await (await fetch(`http://127.0.0.1:${PORT}/json/list`)).json()
const page = list.find(t => t.type === 'page' && !/devtools/.test(t.url))
if (!page) {
throw new Error('no page target on :' + PORT)
}
const ws = new WebSocket(page.webSocketDebuggerUrl, { maxPayload: 256 * 1024 * 1024 })
await new Promise((resolve, reject) => {
ws.once('open', resolve)
ws.once('error', reject)
})
let id = 0
const pending = new Map()
ws.on('message', raw => {
const msg = JSON.parse(raw)
if (msg.id && pending.has(msg.id)) {
const { resolve, reject } = pending.get(msg.id)
pending.delete(msg.id)
msg.error ? reject(new Error(msg.error.message)) : resolve(msg.result)
}
})
const send = (method, params = {}) =>
new Promise((resolve, reject) => {
const mid = ++id
pending.set(mid, { resolve, reject })
ws.send(JSON.stringify({ id: mid, method, params }))
})
await send('Runtime.enable')
const evaluate = async expression => {
const r = await send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true })
if (r.exceptionDetails) {
throw new Error(r.exceptionDetails.exception?.description ?? 'eval failed')
}
return r.result?.value
}
return { evaluate, close: () => ws.close(), send }
}
const FPS = seconds => `
(async () => {
const frames = []
let last = performance.now()
const end = last + ${seconds * 1000}
while (performance.now() < end) {
await new Promise(r => requestAnimationFrame(r))
const now = performance.now()
frames.push(now - last)
last = now
}
const total = frames.reduce((a, b) => a + b, 0)
const sorted = [...frames].sort((a, b) => a - b)
const pct = p => sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * p))]
return {
fps: Math.round((frames.length / total) * 1000 * 10) / 10,
p95: Math.round(pct(0.95) * 10) / 10,
worst: Math.round(sorted[sorted.length - 1] * 10) / 10,
slow33: frames.filter(f => f > 33).length,
n: frames.length
}
})()
`
// LoAF recorder for a window of work driven inside `body`.
const WITH_LOAF = body => `
(async () => {
const lofs = []
const po = new PerformanceObserver(list => {
for (const e of list.getEntries()) {
lofs.push({
ms: Math.round(e.duration),
block: Math.round(e.blockingDuration ?? 0),
style: e.styleAndLayoutStart ? Math.round(e.startTime + e.duration - e.styleAndLayoutStart) : 0,
scripts: (e.scripts ?? []).filter(s => s.duration >= 5).map(s =>
(s.invokerType ?? '') + ':' + (s.invoker ?? s.sourceFunctionName ?? '?') + '@' +
((s.sourceURL ?? '').split('/').pop() ?? '') + ' ' + Math.round(s.duration) + 'ms')
})
}
})
po.observe({ type: 'long-animation-frame', buffered: false })
const frames = []
let last = performance.now()
let stop = false
const tick = () => {
if (stop) return
const now = performance.now()
frames.push(now - last)
last = now
requestAnimationFrame(tick)
}
requestAnimationFrame(tick)
${body}
stop = true
po.disconnect()
const total = frames.reduce((a, b) => a + b, 0)
const sorted = [...frames].sort((a, b) => a - b)
const pct = p => sorted.length ? sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * p))] : 0
return {
fps: total ? Math.round((frames.length / total) * 1000 * 10) / 10 : 0,
p95: Math.round(pct(0.95) * 10) / 10,
worst: sorted.length ? Math.round(sorted[sorted.length - 1] * 10) / 10 : 0,
slow33: frames.filter(f => f > 33).length,
longFrames: lofs.slice(0, 10)
}
})()
`
const DRAG_BODY = `
const handle = document.querySelector('[role="separator"]')
if (!handle) throw new Error('no sash')
const box = handle.getBoundingClientRect()
const y = box.top + box.height / 2
const x0 = box.left + box.width / 2
let x = x0
const opts = { bubbles: true, cancelable: true, pointerId: 1, pointerType: 'mouse', isPrimary: true, button: 0, buttons: 1 }
handle.dispatchEvent(new PointerEvent('pointerdown', { ...opts, clientX: x, clientY: y }))
for (let i = 0; i < 60; i++) {
x += (i < 30 ? 3 : -3)
window.dispatchEvent(new PointerEvent('pointermove', { ...opts, clientX: x, clientY: y }))
await new Promise(r => requestAnimationFrame(r))
}
window.dispatchEvent(new PointerEvent('pointerup', { ...opts, buttons: 0, clientX: x, clientY: y }))
`
const TYPE_BODY = `
const el = [...document.querySelectorAll('[contenteditable="true"]')].find(e => !e.closest('[data-pane-hidden]'))
if (!el) throw new Error('no composer')
el.focus()
for (let i = 0; i < 40; i++) {
const ch = 'the quick brown fox '[i % 20]
el.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: ch }))
el.textContent += ch
el.dispatchEvent(new InputEvent('input', { bubbles: true, data: ch, inputType: 'insertText' }))
el.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, key: ch }))
await new Promise(r => requestAnimationFrame(r))
}
`
// Session switching: click each sidebar session row, time route->settled.
const SWITCH = `
(async () => {
const pick = () => [...document.querySelectorAll('[data-slot="row-button"]')]
.filter(el => el.offsetParent !== null).slice(0, 8)
if (pick().length < 2) {
throw new Error('found ' + pick().length + ' session rows')
}
const times = []
const labels = []
for (let i = 0; i < Math.min(pick().length, 6); i++) {
// Re-query each iteration: a switch can re-render the sidebar and
// detach the previously captured nodes.
const row = pick()[i]
if (!row) break
labels.push((row.textContent || '').slice(0, 24))
const t0 = performance.now()
row.click()
let calm = 0
while (calm < 2 && performance.now() - t0 < 5000) {
const f0 = performance.now()
await new Promise(r => requestAnimationFrame(r))
const dt = performance.now() - f0
calm = dt < 20 ? calm + 1 : 0
}
times.push(Math.round(performance.now() - t0))
await new Promise(r => setTimeout(r, 400))
}
return { switches: times, labels, avg: Math.round(times.reduce((a, b) => a + b, 0) / times.length) }
})()
`
const cmd = process.argv[2] ?? 'status'
const arg = process.argv[3]
const { evaluate, close } = await attach()
try {
if (cmd === 'status') {
const r = await evaluate(`JSON.stringify({
url: location.hash,
perfLive: typeof window.__PERF_LIVE__ !== 'undefined',
renderCounts: typeof window.__RENDER_COUNTS__ !== 'undefined',
tiles: document.querySelectorAll('[data-tree-group]').length,
sessions: document.querySelectorAll('[data-slot*="session-row"], [data-session-row]').length,
composers: [...document.querySelectorAll('[contenteditable="true"]')].length
})`)
console.log(r)
} else if (cmd === 'fps') {
console.log(JSON.stringify(await evaluate(FPS(Number(arg ?? 4)))))
} else if (cmd === 'drag') {
const r = await evaluate(WITH_LOAF(DRAG_BODY))
console.log('drag', JSON.stringify({ fps: r.fps, p95: r.p95, worst: r.worst, slow33: r.slow33 }))
for (const lf of r.longFrames) {
console.log(`${lf.ms}ms block=${lf.block} style=${lf.style}${lf.scripts.join(' | ') || '(no script ≥5ms)'}`)
}
} else if (cmd === 'type') {
const r = await evaluate(WITH_LOAF(TYPE_BODY))
console.log('type', JSON.stringify({ fps: r.fps, p95: r.p95, worst: r.worst, slow33: r.slow33 }))
for (const lf of r.longFrames) {
console.log(`${lf.ms}ms block=${lf.block} style=${lf.style}${lf.scripts.join(' | ') || '(no script ≥5ms)'}`)
}
} else if (cmd === 'switch') {
console.log(JSON.stringify(await evaluate(SWITCH)))
} else if (cmd === 'eval') {
console.log(JSON.stringify(await evaluate(arg)))
} else if (cmd === 'profile') {
// CPU-profile one session switch via CDP Profiler (Document Policy blocks
// the in-page Profiler API, CDP is exempt). arg = row label prefix.
await send('Profiler.enable')
await send('Profiler.setSamplingInterval', { interval: 200 })
await send('Profiler.start')
const r = await evaluate(`
(async () => {
const pick = () => [...document.querySelectorAll('[data-slot="row-button"]')].filter(el => el.offsetParent !== null)
const row = pick().find(el => (el.textContent || '').startsWith(${JSON.stringify(arg ?? 'GUI')}))
if (!row) return 'row not found'
const t0 = performance.now()
row.click()
let calm = 0
while (calm < 3 && performance.now() - t0 < 6000) {
const f0 = performance.now()
await new Promise(r => requestAnimationFrame(r))
calm = (performance.now() - f0) < 20 ? calm + 1 : 0
}
return Math.round(performance.now() - t0)
})()
`)
const { profile } = await send('Profiler.stop')
// Self-time per function.
const hitById = new Map()
for (let i = 0; i < profile.samples.length; i++) {
const id = profile.samples[i]
const dt = profile.timeDeltas[i] ?? 0
hitById.set(id, (hitById.get(id) ?? 0) + dt)
}
const rows = []
for (const node of profile.nodes) {
const us = hitById.get(node.id)
if (!us || us < 5000) continue
const f = node.callFrame
rows.push([Math.round(us / 1000), `${f.functionName || '(anon)'} @ ${(f.url || '').split('/').pop()}:${f.lineNumber}`])
}
rows.sort((a, b) => b[0] - a[0])
console.log('switch took', r, 'ms — top self-time:')
for (const [ms, name] of rows.slice(0, 18)) {
console.log(` ${String(ms).padStart(6)}ms ${name}`)
}
} else if (cmd === 'send') {
const r = await evaluate(`
(async () => {
const el = [...document.querySelectorAll('[contenteditable="true"]')].find(e => !e.closest('[data-pane-hidden]'))
if (!el) return 'no composer'
el.focus()
document.execCommand('insertText', false, ${JSON.stringify(arg ?? 'hello')})
await new Promise(r => setTimeout(r, 120))
el.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, cancelable: true, key: 'Enter' }))
return 'sent'
})()
`)
console.log(r)
} else {
console.log('unknown cmd', cmd)
}
} finally {
close()
}
@@ -0,0 +1,112 @@
/**
* Regression #87758: a local desktop pack must never enter electron-builder's
* publish path, and publish resolution must succeed when something else does
* enter it.
*
* These call the real app-builder-lib resolver rather than asserting on the
* text of the pack script, so they fail if electron-builder changes the
* behavior we depend on — not when someone reformats package.json.
*/
import assert from 'node:assert/strict'
import { createRequire } from 'node:module'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { afterEach, beforeEach, describe, test } from 'vitest'
const require = createRequire(import.meta.url)
const desktopDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
const desktopPkg = require(path.join(desktopDir, 'package.json'))
const { getPublishConfigs } = require('app-builder-lib/out/publish/PublishManager.js')
const { getRepositoryInfo } = require('app-builder-lib/out/util/repositoryInfo.js')
/**
* The slice of PlatformPackager that getPublishConfigs actually reads. The
* repositoryInfo getter mirrors what electron-builder does for real: resolve
* from THIS package's metadata with projectDir = apps/desktop.
*/
function fakePackager(metadata) {
const info = {
get repositoryInfo() {
return getRepositoryInfo(desktopDir, metadata, null)
},
appInfo: { version: '0.0.0', channel: null, updaterCacheDirName: 'hermes' },
config: {},
options: {}
}
return {
platformSpecificBuildOptions: {},
config: {},
info,
platform: { name: 'linux' },
appInfo: info.appInfo,
expandMacro: value => value
}
}
const TOKEN_VARS = ['GH_TOKEN', 'GITHUB_TOKEN', 'GITLAB_TOKEN', 'KEYGEN_TOKEN', 'BITBUCKET_TOKEN']
let savedEnv
beforeEach(() => {
savedEnv = {}
for (const key of TOKEN_VARS) {
savedEnv[key] = process.env[key]
delete process.env[key]
}
})
afterEach(() => {
for (const key of TOKEN_VARS) {
if (savedEnv[key] === undefined) delete process.env[key]
else process.env[key] = savedEnv[key]
}
})
describe('local desktop pack stays out of the publish path', () => {
test('the pack script pins an explicit publish policy', () => {
// electron-builder 26 infers `onTagOrDraft` from CI when --publish is
// absent, and `hermes desktop` runs the pack with CI=1 (_npm_lifecycle_env).
// v27 drops the implicit behavior, so being explicit is also forward-safe.
const pack = desktopPkg.scripts.pack
assert.match(pack, /--dir\b/)
assert.match(pack, /--publish\s+never\b/)
})
test('publish resolution succeeds with a GitHub token present', async () => {
// The #87758 failure mode: CI=1 makes isPublish true, a GITHUB_TOKEN in the
// environment auto-selects the github provider, and the provider needs a
// repository it cannot find — apps/desktop has no .git/config of its own
// and app-builder-lib does not walk up to the workspace root.
process.env.GITHUB_TOKEN = 'x'
const configs = await getPublishConfigs(
fakePackager(desktopPkg),
null,
null,
/* errorIfCannot */ true
)
assert.ok(Array.isArray(configs) && configs.length > 0)
assert.equal(configs[0].provider, 'github')
assert.equal(configs[0].owner, 'NousResearch')
assert.equal(configs[0].repo, 'hermes-agent')
})
test('a package without the repository field is what breaks resolution', async () => {
// Guards the fix itself: proves the assertion above passes because of the
// repository field, not because the throw is unreachable.
process.env.GITHUB_TOKEN = 'x'
const { repository, ...withoutRepository } = desktopPkg
assert.ok(repository, 'apps/desktop/package.json must declare a repository')
await assert.rejects(
() => getPublishConfigs(fakePackager(withoutRepository), null, null, true),
/Cannot detect repository by \.git\/config/
)
})
test('resolution is quiet when no publish token is configured', async () => {
const configs = await getPublishConfigs(fakePackager(desktopPkg), null, null, true)
assert.deepEqual(configs, [])
})
})
@@ -0,0 +1,77 @@
import { existsSync, writeFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { execFile } from 'node:child_process'
function run(command, args) {
return new Promise((resolve, reject) => {
execFile(command, args, (error, stdout, stderr) => {
if (error) {
// Intentionally omit args from the rejection message: callers pass
// notarization credentials (key id, issuer, key file path) here, and
// surfacing them in error output would land in CI logs.
reject(new Error(`${command} failed: ${stderr?.trim() || stdout?.trim() || error.message}`))
return
}
resolve()
})
})
}
function inlineKeyLooksValid(value) {
return value.includes('BEGIN PRIVATE KEY') && value.includes('END PRIVATE KEY')
}
function resolveApiKeyPath(rawValue) {
const value = String(rawValue || '').trim()
if (!value) return { keyPath: '', cleanup: () => {} }
if (existsSync(value)) {
return { keyPath: value, cleanup: () => {} }
}
if (!inlineKeyLooksValid(value)) {
throw new Error('APPLE_API_KEY must be a file path or inline .p8 key content')
}
const tempPath = join(tmpdir(), `hermes-notary-${Date.now()}-${process.pid}.p8`)
writeFileSync(tempPath, value, 'utf8')
return {
keyPath: tempPath,
cleanup: () => rmSync(tempPath, { force: true })
}
}
async function main() {
const artifactPath = process.argv[2]
if (!artifactPath || !existsSync(artifactPath)) {
throw new Error(`Missing artifact to notarize: ${artifactPath || '(none)'}`)
}
const profile = String(process.env.APPLE_NOTARY_PROFILE || '').trim()
if (profile) {
await run('xcrun', ['notarytool', 'submit', artifactPath, '--keychain-profile', profile, '--wait'])
await run('xcrun', ['stapler', 'staple', '-v', artifactPath])
return
}
const keyId = String(process.env.APPLE_API_KEY_ID || '').trim()
const issuer = String(process.env.APPLE_API_ISSUER || '').trim()
const rawApiKey = process.env.APPLE_API_KEY
if (!rawApiKey || !keyId || !issuer) {
throw new Error('APPLE_API_KEY, APPLE_API_KEY_ID, and APPLE_API_ISSUER are required')
}
const { keyPath, cleanup } = resolveApiKeyPath(rawApiKey)
try {
await run('xcrun', ['notarytool', 'submit', artifactPath, '--key', keyPath, '--key-id', keyId, '--issuer', issuer, '--wait'])
await run('xcrun', ['stapler', 'staple', '-v', artifactPath])
} finally {
cleanup()
}
}
main().catch(() => {
console.error('Notarization failed. Check configuration and command output in secure CI logs.')
process.exit(1)
})
+100
View File
@@ -0,0 +1,100 @@
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { execFile } from 'node:child_process'
function run(command, args) {
return new Promise((resolve, reject) => {
execFile(command, args, (error, stdout, stderr) => {
if (error) {
reject(
new Error(
`${command} ${args.join(' ')} failed: ${stderr?.trim() || stdout?.trim() || error.message}`
)
)
return
}
resolve({ stdout, stderr })
})
})
}
function inlineKeyLooksValid(value) {
return value.includes('BEGIN PRIVATE KEY') && value.includes('END PRIVATE KEY')
}
function resolveApiKeyPath(rawValue) {
const value = String(rawValue || '').trim()
if (!value) return { keyPath: '', cleanup: () => {} }
if (fs.existsSync(value)) {
return { keyPath: value, cleanup: () => {} }
}
if (!inlineKeyLooksValid(value)) {
throw new Error('APPLE_API_KEY must be a file path or inline .p8 key content')
}
const tempPath = path.join(os.tmpdir(), `hermes-notary-${Date.now()}-${process.pid}.p8`)
fs.writeFileSync(tempPath, value, 'utf8')
return {
keyPath: tempPath,
cleanup: () => {
try {
fs.rmSync(tempPath, { force: true })
} catch {
// Best-effort cleanup.
}
}
}
}
export default async function notarize(context) {
const { electronPlatformName, appOutDir, packager } = context
if (electronPlatformName !== 'darwin') return
const appName = packager.appInfo.productFilename
const appPath = path.join(appOutDir, `${appName}.app`)
if (!fs.existsSync(appPath)) {
throw new Error(`Cannot notarize missing app bundle: ${appPath}`)
}
const profile = String(process.env.APPLE_NOTARY_PROFILE || '').trim()
if (profile) {
const zipPath = path.join(appOutDir, `${appName}.zip`)
await run('ditto', ['-c', '-k', '--sequesterRsrc', '--keepParent', appPath, zipPath])
await run('xcrun', ['notarytool', 'submit', zipPath, '--keychain-profile', profile, '--wait'])
await run('xcrun', ['stapler', 'staple', '-v', appPath])
try {
fs.rmSync(zipPath, { force: true })
} catch {
// Best-effort cleanup.
}
return
}
const keyId = String(process.env.APPLE_API_KEY_ID || '').trim()
const issuer = String(process.env.APPLE_API_ISSUER || '').trim()
const rawApiKey = process.env.APPLE_API_KEY
if (!rawApiKey || !keyId || !issuer) {
console.log(
'Skipping notarization: APPLE_API_KEY, APPLE_API_KEY_ID, and APPLE_API_ISSUER are not fully configured.'
)
return
}
const { keyPath, cleanup } = resolveApiKeyPath(rawApiKey)
const zipPath = path.join(appOutDir, `${appName}.zip`)
try {
await run('ditto', ['-c', '-k', '--sequesterRsrc', '--keepParent', appPath, zipPath])
await run('xcrun', ['notarytool', 'submit', zipPath, '--key', keyPath, '--key-id', keyId, '--issuer', issuer, '--wait'])
await run('xcrun', ['stapler', 'staple', '-v', appPath])
} finally {
try {
fs.rmSync(zipPath, { force: true })
} catch {
// Best-effort cleanup.
}
cleanup()
}
}
@@ -0,0 +1,64 @@
import fs from 'node:fs'
import path from 'node:path'
if (process.platform !== 'darwin') {
process.exit(0)
}
const desktopRoot = path.resolve(import.meta.dirname, '..')
const repoRoot = path.resolve(desktopRoot, '..', '..')
const electronMacPath = path.join(repoRoot, 'node_modules', 'app-builder-lib', 'out', 'electron', 'electronMac.js')
const marker = 'hermes-macos-electron-binary-fallback'
const needle = ` await Promise.all([
doRename(path.join(contentsPath, "MacOS"), electronBranding.productName, appPlist.CFBundleExecutable),
(0, builder_util_1.unlinkIfExists)(path.join(appOutDir, "LICENSE")),
(0, builder_util_1.unlinkIfExists)(path.join(appOutDir, "LICENSES.chromium.html")),
]);`
const replacement = ` // ${marker}: electron-builder 26.8.x can sometimes copy
// Electron.app without its main MacOS/Electron binary before this rename.
// Restore it from the installed Electron runtime so local desktop installs
// do not fail with ENOENT during macOS arm64 packaging.
const macosDir = path.join(contentsPath, "MacOS");
const bundledElectronBinary = path.join(macosDir, electronBranding.productName);
if (!fs.existsSync(bundledElectronBinary)) {
const candidates = [
path.join(packager.info.framework.distMacOsAppName, "Contents", "MacOS", electronBranding.productName),
// npm may nest the workspace-only electron devDep under
// apps/desktop/node_modules (process.cwd() during pack), or hoist
// it to the repo root. Try the workspace-local install first, then
// the root hoist, so the fallback works under either layout.
path.join(process.cwd(), "node_modules", "electron", "dist", "Electron.app", "Contents", "MacOS", electronBranding.productName),
path.join(process.cwd(), "..", "..", "node_modules", "electron", "dist", "Electron.app", "Contents", "MacOS", electronBranding.productName),
];
const sourceBinary = candidates.find(candidate => fs.existsSync(candidate));
if (sourceBinary == null) {
throw new Error("Electron binary missing from packaged app and Electron runtime: " + bundledElectronBinary);
}
await (0, promises_1.copyFile)(sourceBinary, bundledElectronBinary);
await (0, promises_1.chmod)(bundledElectronBinary, 0o755);
}
await Promise.all([
doRename(macosDir, electronBranding.productName, appPlist.CFBundleExecutable),
(0, builder_util_1.unlinkIfExists)(path.join(appOutDir, "LICENSE")),
(0, builder_util_1.unlinkIfExists)(path.join(appOutDir, "LICENSES.chromium.html")),
]);`
if (!fs.existsSync(electronMacPath)) {
console.warn(`[patch-electron-builder] skipped: ${electronMacPath} not found`)
process.exit(0)
}
const source = fs.readFileSync(electronMacPath, 'utf8')
if (source.includes(marker)) {
console.log('[patch-electron-builder] macOS Electron binary fallback already applied')
process.exit(0)
}
if (!source.includes(needle)) {
console.warn('[patch-electron-builder] skipped: expected electronMac.js shape not found')
process.exit(0)
}
fs.writeFileSync(electronMacPath, source.replace(needle, replacement))
console.log('[patch-electron-builder] applied macOS Electron binary fallback')
+93
View File
@@ -0,0 +1,93 @@
# Desktop perf harness
One systematized way to measure desktop rendering/interaction performance,
diff it against a committed baseline, and fail on regressions. It replaces the
dozen one-off `measure-*` / `profile-*` scripts that each reinvented the CDP
client, arg parsing, stats, and output (and never had a baseline).
## Quick start
```bash
# Isolated instance (recommended) — no running app or LLM credits needed.
# Its own --user-data-dir + HERMES_HOME means it never collides with `hgui`.
npm run perf -- --spawn
# Or: launch an isolated instance once, attach repeatedly (faster iteration).
npm run perf:serve # leaves an instance on :9222
npm run perf # attaches, runs the CI suite, gates on baseline
# One scenario, with a CPU profile:
npm run perf -- stream --cpuprofile --tokens 800
# Representative PRODUCTION numbers (minified React, not the ~3x-slower dev build):
npm run perf -- cold-start stream keystroke transcript --spawn --prod
# Re-capture the baseline on your reference device, then commit baseline.json:
npm run perf -- cold-start stream keystroke transcript --spawn --prod --update-baseline
```
## Dev vs prod
By default the harness measures the **dev** renderer (fast to spin up, good for
relative regression checks). Pass `--prod` (with `--spawn`) to build a
production renderer *with the probe included* (`VITE_PERF_PROBE=1`) and measure
minified React — the representative shipped numbers. The committed baseline is
captured with `--prod`.
## Why isolation matters
The measurement this harness exists to run was historically blocked: a running
`hgui` holds the Electron single-instance lock, so a second instance quit
immediately. `--spawn` / `perf:serve` launch with their own `--user-data-dir`
(separate lock scope), their own `HERMES_HOME` (separate backend + sessions),
and their own `--remote-debugging-port`. Synthetic scenarios drive `$messages`
directly via `window.__PERF_DRIVE__`, so no LLM credits are spent.
## Scenarios
| scenario | tier | measures | replaces |
|---|---|---|---|
| `stream` | ci | streaming longtasks, frame p95/p99, mutation cadence | measure-synthetic-stream, profile-synth-stream, profile-long-stream |
| `stream --real` | backend | same, from a real LLM stream | measure-real-stream, profile-real-stream |
| `keystroke` | ci | composer keystroke → paint latency | measure-latency, profile-typing, leak-typing |
| `transcript` | ci | large-transcript mount + paint cost | (new) |
| `render-churn` | ci | per-component render attribution + store churn while N tabs stream | (new) |
| `idle-cost` | report | busy-but-silent tiles: idle commit rate, + fps while resizing / typing | (new) |
| `right-pane` | report | file tree + persistent xterm tabs under chat/terminal output and split dragging | (new) |
| `cold-start` | cold | launch → CDP → driver → first paint (fresh spawn/run) | (new) |
| `first-token` | backend | Enter → first assistant token painted (TTFT) | (new) |
| `submit` | backend | Enter → cleared → user msg painted, scroll jump | measure-submit, measure-jump |
| `session-switch` | backend | route → first-paint → settle | profile-session-switch |
| `session-load` | backend | how far a session's transcript moves after first paint | (new) |
| `profile-switch` | backend | rail click → sidebar settled | measure-profile-switch |
`ci` + `cold` scenarios need no backend/credits and are gated against
`baseline.json` (`cold-start` requires `--spawn` since it measures a fresh
launch, and must be run in its own invocation). `backend` scenarios need a live
backend (and `--spawn` or a real session/credits) and are report-only.
CPU profiling is a cross-cutting `--cpuprofile` flag on any scenario (it wraps
the run in `Profiler.start/stop` and prints a top-self-time table), replacing
every standalone `profile-*` script.
## Adding a scenario
Create `scenarios/<name>.mjs` exporting `{ name, tier, description, run(cdp, opts) }`
where `run` returns `{ metrics, detail }` (metrics = flat numbers, lower is
better), then register it in `scenarios/index.mjs`. If it's `ci`, add a
`baseline.json` entry (or run `--update-baseline`).
## Layout
- `lib/cdp.mjs` — the one CDP client + target discovery + typing + CPU-profile wrapper + DOM selectors.
- `lib/stats.mjs` — percentiles, histograms, CPU-profile self-time ranking.
- `lib/baseline.mjs` — load/compare/update the baseline + regression gate.
- `lib/launch.mjs` — attach, or spawn a fully isolated instance.
- `scenarios/` — one module per measurement.
- `run.mjs` — entrypoint. `serve.mjs` — standalone isolated launcher.
## Not migrated (kept as dev utilities)
`eval.mjs`, `reload.mjs`, `reload-renderer.mjs`, `probe-renderer.mjs`,
`probe-thread.mjs`, `click-session.mjs`, `diag-*.mjs` are interactive dev
helpers, not benchmarks. They can adopt `lib/cdp.mjs` in a follow-up.
+79
View File
@@ -0,0 +1,79 @@
{
"_meta": {
"note": "Median of 5 runs, darwin-arm64, `--spawn --prod` (PRODUCTION minified renderer, real boot \u2014 no fake-boot). Representative shipped numbers, not dev-inflated. cold-start reuses one profile so the V8 code cache is WARM (what users get after first launch, ~1.0s); a fresh-profile first launch is ~+400ms (measure with `--cold-fresh`). Marks are process-spawn wall clock (spawn_to_*) or renderer nav-relative (dom_*). Re-baseline per device with `--update-baseline`; tolerances loose for cross-machine/disk variance.",
"platform": "darwin-arm64",
"node": "v24.11.0",
"updated": "2026-07-27T00:30:11.290Z"
},
"scenarios": {
"stream": {
"tolerance": {
"tolFrac": 0.6,
"tolAbs": 5
},
"metrics": {
"longtasks_n": 1,
"longtask_max_ms": 67,
"frame_p95_ms": 22,
"frame_p99_ms": 23.7,
"slow_frames_33": 1,
"intermut_p95_ms": 36.1
}
},
"keystroke": {
"tolerance": {
"tolFrac": 0.6,
"tolAbs": 4
},
"metrics": {
"keystroke_p50_ms": 2.1,
"keystroke_p95_ms": 8.7,
"keystroke_p99_ms": 16.9,
"keystroke_slow_16": 2
}
},
"transcript": {
"tolerance": {
"tolFrac": 0.75,
"tolAbs": 40
},
"metrics": {
"transcript_mount_ms": 145,
"transcript_longtask_ms": 82,
"transcript_longtask_max_ms": 82
}
},
"cold-start": {
"tolerance": {
"tolFrac": 0.6,
"tolAbs": 150
},
"metrics": {
"spawn_to_cdp_ms": 606,
"spawn_to_driver_ms": 984,
"dom_interactive_ms": 324,
"dom_content_loaded_ms": 574,
"nav_to_read_ms": 721
}
},
"multitab": {
"metrics": {
"longtasks_n": 0,
"longtask_max_ms": 0,
"frame_p95_ms": 29.1,
"frame_p99_ms": 36.2,
"slow_frames_33": 9
}
},
"render-churn": {
"metrics": {
"sidebar_renders": 0,
"sidebar_wasted": 0,
"wasted_renders": 1704,
"total_renders": 8221,
"commits": 1352,
"wasted_notifies": 0
}
}
}
}
@@ -0,0 +1,261 @@
"""Measure the gateway's attach-RPC dispatch, against the real dispatcher.
Every attach handler (image.attach, image.attach_bytes, file.attach,
clipboard.paste, pdf.attach) resolves its session through ``_sess()``, which
blocks on the deferred agent build. None of them is in ``_LONG_HANDLERS``, so
that block happens INLINE on the socket reader thread.
This drives the real ``tui_gateway.server.dispatch`` with a session whose
agent build has not completed, and times it. ``prompt.submit`` (which uses
``_sess_nowait``) is timed alongside as the control — it is the path that
stays instant today.
python3 scripts/perf/gateway_attach_bench.py [--build-seconds 8] [--rounds 3]
"""
from __future__ import annotations
import argparse
import base64
import os
import statistics
import sys
import tempfile
import threading
import time
from pathlib import Path
REPO = Path(__file__).resolve().parents[4]
sys.path.insert(0, str(REPO))
os.environ.setdefault("HERMES_HOME", tempfile.mkdtemp(prefix="hermes-bench-home-"))
class CollectTransport:
"""Stand-in for the WS transport: records frames, never touches a socket."""
def __init__(self) -> None:
self.frames: list[dict] = []
self.lock = threading.Lock()
def write(self, obj: dict) -> bool:
with self.lock:
self.frames.append(obj)
return True
def close(self) -> None:
return None
def make_session(server, sid: str, *, build_seconds: float, home: Path) -> dict:
"""A session whose deferred agent build is still running.
Mirrors the shape ``_deferred_build`` leaves behind: an unset ``agent_ready``
event plus a live build thread. That is exactly the state a session is in
for the first seconds after ``session.create`` — which is when a user
pastes their first image.
"""
ready = threading.Event()
session: dict = {
"agent": None,
"agent_ready": ready,
"agent_error": None,
"attached_images": [],
"cwd": str(home),
"history": [],
"history_lock": threading.RLock(),
"history_version": 0,
"image_counter": 0,
"profile_home": str(home),
"running": False,
"session_key": sid,
"transport": None,
}
def build() -> None:
time.sleep(build_seconds)
ready.set()
thread = threading.Thread(target=build, daemon=True)
session["_agent_build_thread"] = thread
thread.start()
server._sessions[sid] = session
return session
def png_bytes(kb: int) -> bytes:
body = bytearray(b"\x89PNG\r\n\x1a\n")
body.extend(bytes((i * 37) & 0xFF for i in range(kb * 1024)))
return bytes(body)
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--build-seconds", type=float, default=8.0)
ap.add_argument("--rounds", type=int, default=3)
ap.add_argument("--kb", type=int, default=900)
args = ap.parse_args()
from tui_gateway import server
# The build is already in flight for these sessions (that is the state the
# bench recreates), so the "start one if none is running" call is a no-op.
# Without this stub the real builder races the bench's controlled one and
# completes instantly, hiding the very wait being measured.
server._start_agent_build = lambda sid, session: None
# Keep the run readable: session.info frames go to the transport, not stdout.
server._emit = lambda *a, **k: None
home = Path(os.environ["HERMES_HOME"])
home.mkdir(parents=True, exist_ok=True)
content_b64 = base64.b64encode(png_bytes(args.kb)).decode("ascii")
scratch = home / "scratch.txt"
scratch.write_text("hello from the bench\n")
image_on_disk = home / "on-disk.png"
image_on_disk.write_bytes(png_bytes(args.kb))
pdf_on_disk = home / "doc.pdf"
pdf_on_disk.write_bytes(b"%PDF-1.4\n" + b"0" * 2048 + b"\n%%EOF\n")
calls = [
(
"image.attach_bytes",
lambda sid: {
"session_id": sid,
"content_base64": content_b64,
"filename": "bench.png",
},
),
(
"image.attach",
lambda sid: {"session_id": sid, "path": str(image_on_disk)},
),
(
"file.attach",
lambda sid: {
"session_id": sid,
"name": "scratch.txt",
"path": str(scratch),
},
),
(
"pdf.attach",
lambda sid: {"session_id": sid, "path": str(pdf_on_disk)},
),
(
"clipboard.paste",
lambda sid: {"session_id": sid},
),
(
"image.detach",
lambda sid: {"session_id": sid, "path": "/tmp/nothing.png"},
),
(
"prompt.submit",
lambda sid: {"session_id": sid, "text": "control: plain text"},
),
]
print(
f"agent build takes {args.build_seconds:.1f}s; "
f"image is {args.kb} KB; {args.rounds} rounds\n"
)
print(f"{'rpc':<22} {'in _LONG_HANDLERS':<19} {'mean':>8} {'max':>8} blocks reader?")
for method, build_params in calls:
samples: list[float] = []
for round_index in range(args.rounds):
sid = f"bench-{method}-{round_index}"
make_session(server, sid, build_seconds=args.build_seconds, home=home)
transport = CollectTransport()
req = {
"jsonrpc": "2.0",
"id": round_index,
"method": method,
"params": build_params(sid),
}
start = time.perf_counter()
try:
server.dispatch(req, transport)
except Exception as exc: # noqa: BLE001 - report, don't mask
print(f" ! {method} raised {type(exc).__name__}: {exc}")
samples.append(time.perf_counter() - start)
server._sessions.pop(sid, None)
pooled = method in server._LONG_HANDLERS
mean = statistics.mean(samples)
worst = max(samples)
verdict = "no (pooled)" if pooled else ("YES" if mean > 1.0 else "no")
print(
f"{method:<22} {str(pooled):<19} {mean:>7.2f}s {worst:>7.2f}s {verdict}"
)
print(
"\ndispatch() returns immediately for pooled handlers, so a pooled timing\n"
"is the enqueue cost — the work still happens, just off the reader thread."
)
_report_surfaces()
return 0
def _report_surfaces() -> None:
"""Which surfaces can even reach this code path.
The stall lives in the gateway's session resolver, so a surface is exposed
only if it attaches over the gateway. That is a fact about the call graph
rather than a timing, so it is read out of the source — and it moves if
the call graph moves.
"""
print("\n\n=== which surfaces reach the gateway attach RPCs ===\n")
root = Path(__file__).resolve().parents[4]
attach_rpcs = ("image.attach", "image.attach_bytes", "file.attach", "clipboard.paste")
surfaces = {
"CLI (cli.py)": [root / "cli.py"],
"TUI (ui-tui)": sorted((root / "ui-tui" / "src").rglob("*.ts")),
"Desktop (apps/desktop)": sorted((root / "apps" / "desktop" / "src").rglob("*.ts")),
}
for label, paths in surfaces.items():
hits: set[str] = set()
for path in paths:
try:
text = path.read_text(encoding="utf-8", errors="ignore")
except OSError:
continue
for rpc in attach_rpcs:
if f"'{rpc}'" in text or f'"{rpc}"' in text:
hits.add(rpc)
if hits:
print(f" {label:<24} EXPOSED — calls {', '.join(sorted(hits))}")
else:
print(f" {label:<24} not exposed — no gateway attach RPC")
print(
"\n CLI attaches inline in its own turn path (cli.py → image_routing) with\n"
" the agent already constructed. There is no gateway session to resolve,\n"
" so the stall is structurally unreachable — matching the ~4s report.\n"
"\n The TUI calls the SAME RPCs and was equally exposed. What differed was\n"
" hit rate, not code path: Desktop mints sessions constantly (new chat,\n"
" tabs, tiles), so a paste routinely lands inside the seconds-long window\n"
" while a fresh session's agent is still building. A TUI user launches\n"
" once and the build finishes while they type."
)
return None
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,307 @@
// Measures the desktop image-attach pipeline stage by stage on a real image,
// against the real renderer helpers. No Electron, no LLM — just the transforms
// an attached image goes through between the paperclip and prompt.submit.
//
// node scripts/perf/image-attach-bench.mjs [--kb 900] [--rounds 7]
import { readFileSync, writeFileSync, mkdtempSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { performance } from 'node:perf_hooks'
const args = process.argv.slice(2)
const flag = (name, fallback) => {
const i = args.indexOf(`--${name}`)
return i >= 0 ? Number(args[i + 1]) : fallback
}
const ROUNDS = flag('rounds', 7)
const SIZES_KB = args.includes('--kb') ? [flag('kb', 900)] : [120, 900, 3200]
const dir = mkdtempSync(join(tmpdir(), 'hermes-img-bench-'))
/** A PNG-shaped byte blob of a given size. The pipeline treats it as opaque
* bytes everywhere we measure, so the pixels don't matter — the length does. */
function makeImage(kb) {
const bytes = Buffer.alloc(kb * 1024)
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).copy(bytes)
for (let i = 8; i < bytes.length; i += 1) {
bytes[i] = (i * 2654435761) & 0xff
}
const path = join(dir, `img-${kb}kb.png`)
writeFileSync(path, bytes)
return path
}
const stat = samples => {
const s = [...samples].sort((a, b) => a - b)
return {
mean: s.reduce((a, b) => a + b, 0) / s.length,
p50: s[Math.floor(s.length * 0.5)],
p95: s[Math.min(s.length - 1, Math.floor(s.length * 0.95))],
max: s[s.length - 1]
}
}
const time = fn => {
const t0 = performance.now()
const out = fn()
return { ms: performance.now() - t0, out }
}
// --- the stages, transcribed from the shipped code paths -------------------
// electron/hardening.ts :: readFileDataUrlForIpc — main-process side of
// window.hermesDesktop.readFileDataUrl.
const readFileDataUrl = path => {
const data = readFileSync(path)
return `data:image/png;base64,${data.toString('base64')}`
}
// use-prompt-actions/utils.ts :: base64FromDataUrl
const base64FromDataUrl = dataUrl => {
const comma = dataUrl.indexOf(',')
return comma >= 0 ? dataUrl.slice(comma + 1) : ''
}
// The JSON-RPC frame the renderer sends for image.attach_bytes.
const encodeRpcFrame = (base64, filename) =>
JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'image.attach_bytes',
params: { session_id: 'bench', content_base64: base64, filename }
})
// lib/embedded-images.ts :: extractEmbeddedImages — runs on the optimistic
// bubble text on EVERY DirectiveContent render, and the composer's base64
// preview is what it scans.
const DATA_IMAGE_PREFIX = 'data:image/'
const BASE64_MARKER = ';base64,'
const MIN_EMBEDDED_IMAGE_BASE64_LENGTH = 64
const isImageMimeCode = c =>
(c >= 48 && c <= 57) || (c >= 65 && c <= 90) || (c >= 97 && c <= 122) || c === 43 || c === 45 || c === 46 || c === 95
const isBase64Code = c =>
(c >= 48 && c <= 57) || (c >= 65 && c <= 90) || (c >= 97 && c <= 122) || c === 43 || c === 47 || c === 61
function readDataImageUrl(text, start) {
if (!text.startsWith(DATA_IMAGE_PREFIX, start)) {
return null
}
let cursor = start + DATA_IMAGE_PREFIX.length
while (cursor < text.length && isImageMimeCode(text.charCodeAt(cursor))) {
cursor += 1
}
if (cursor === start + DATA_IMAGE_PREFIX.length || !text.startsWith(BASE64_MARKER, cursor)) {
return null
}
cursor += BASE64_MARKER.length
const base64Start = cursor
while (cursor < text.length && isBase64Code(text.charCodeAt(cursor))) {
cursor += 1
}
if (cursor - base64Start < MIN_EMBEDDED_IMAGE_BASE64_LENGTH) {
return null
}
return { end: cursor, url: text.slice(start, cursor) }
}
function extractEmbeddedImages(text) {
if (!text || !text.includes(DATA_IMAGE_PREFIX)) {
return { cleanedText: text, images: [] }
}
const images = []
const pieces = []
let appendCursor = 0
let searchCursor = 0
while (searchCursor < text.length) {
const dataStart = text.indexOf(DATA_IMAGE_PREFIX, searchCursor)
if (dataStart === -1) {
break
}
const dataUrl = readDataImageUrl(text, dataStart)
if (!dataUrl) {
searchCursor = dataStart + DATA_IMAGE_PREFIX.length
continue
}
pieces.push(text.slice(appendCursor, dataStart))
images.push(dataUrl.url)
appendCursor = dataUrl.end
searchCursor = dataUrl.end
}
if (!images.length) {
return { cleanedText: text, images: [] }
}
pieces.push(text.slice(appendCursor))
return {
cleanedText: pieces
.join('')
.replace(/[ \t]+\n/g, '\n')
.replace(/\n{3,}/g, '\n\n')
.trim(),
images
}
}
// lib/render-weight.ts :: payloadCharacters — walks every string in a message's
// content, including the data URL riding in attachmentRefs.
const RENDER_WEIGHT_CHARS = 512
const MAX_MEASURED_MESSAGE_CHARS = 300 * RENDER_WEIGHT_CHARS
const NON_RENDERED_CONTENT_FIELDS = new Set(['id', 'role', 'toolCallId', 'toolName', 'type'])
function payloadCharacters(roots, budget) {
const seen = new WeakSet()
const pending = [...roots]
let characters = 0
while (pending.length > 0 && characters < budget) {
const value = pending.pop()
if (typeof value === 'string') {
characters += Math.min(value.length, budget - characters)
continue
}
if (!value || typeof value !== 'object' || seen.has(value)) {
continue
}
seen.add(value)
if (Array.isArray(value)) {
for (const nested of value) {
pending.push(nested)
}
continue
}
for (const [key, nested] of Object.entries(value)) {
if (!NON_RENDERED_CONTENT_FIELDS.has(key)) {
pending.push(nested)
}
}
}
return characters
}
// store/composer.ts :: cloneDraft — every composer draft stash copies each
// attachment object; previewUrl (the data URL) rides along by reference, but
// the surrounding string ops on the draft still run.
const cloneDraft = draft => ({
attachments: draft.attachments.map(a => ({ ...a })),
text: draft.text
})
// --- run -------------------------------------------------------------------
const results = []
for (const kb of SIZES_KB) {
const path = makeImage(kb)
const rows = {}
const record = (stage, ms) => {
;(rows[stage] ??= []).push(ms)
}
let dataUrl = ''
let base64 = ''
let frame = ''
let bubbleText = ''
for (let round = 0; round < ROUNDS; round += 1) {
// 1. preview read (attachImagePath → attachmentPreviewDataUrl)
const preview = time(() => readFileDataUrl(path))
record('preview_read_dataurl', preview.ms)
dataUrl = preview.out
// 2. submit-time SECOND read of the same file (readImageForRemoteAttach)
const attachRead = time(() => readFileDataUrl(path))
record('attach_read_dataurl', attachRead.ms)
// 3. strip the data: prefix
const strip = time(() => base64FromDataUrl(attachRead.out))
record('base64_from_dataurl', strip.ms)
base64 = strip.out
// 4. JSON-RPC frame for image.attach_bytes
const encode = time(() => encodeRpcFrame(base64, 'img.png'))
record('rpc_frame_encode', encode.ms)
frame = encode.out
// 5. the optimistic bubble carries the data URL as its attachmentRef
bubbleText = dataUrl
const extract = time(() => extractEmbeddedImages(bubbleText))
record('extract_embedded_images', extract.ms)
// 6. render-weight walk over the message holding that ref
const content = [{ type: 'text', text: 'what is this' }, { attachmentRefs: [dataUrl] }]
const weigh = time(() => payloadCharacters(content, MAX_MEASURED_MESSAGE_CHARS))
record('render_weight_walk', weigh.ms)
// 7. draft stash clone with the attachment held
const draft = { attachments: [{ id: 'a', kind: 'image', label: 'i', previewUrl: dataUrl, path }], text: 'hi' }
const clone = time(() => cloneDraft(draft))
record('draft_clone', clone.ms)
}
results.push({
kb,
fileBytes: readFileSync(path).length,
dataUrlChars: dataUrl.length,
rpcFrameChars: frame.length,
rows
})
}
for (const r of results) {
console.log(`\n=== ${r.kb} KB image (${r.fileBytes} bytes on disk) ===`)
console.log(
`data URL: ${r.dataUrlChars.toLocaleString()} chars RPC frame: ${r.rpcFrameChars.toLocaleString()} chars ` +
`(${(r.rpcFrameChars / r.fileBytes).toFixed(2)}x the file)`
)
console.log('')
console.log('stage mean p50 p95 max')
let total = 0
for (const [stage, samples] of Object.entries(r.rows)) {
const s = stat(samples)
total += s.mean
console.log(
`${stage.padEnd(26)} ${s.mean.toFixed(2).padStart(7)} ${s.p50.toFixed(2).padStart(7)} ` +
`${s.p95.toFixed(2).padStart(7)} ${s.max.toFixed(2).padStart(7)} ms`
)
}
console.log(`${'TOTAL (mean)'.padEnd(26)} ${total.toFixed(2).padStart(7)} ms`)
}
@@ -0,0 +1,84 @@
// Baseline + regression gate. This is the capability the old one-off scripts
// never had: measured numbers are compared against a committed baseline so a
// PR that regresses streaming/typing/mount cost fails loudly instead of
// silently drifting.
//
// Every tracked metric is "lower is better" (longtask counts, frame/keystroke
// percentiles, mount ms). A metric regresses when it exceeds
// `baseline * (1 + tolFrac) + tolAbs`. tolAbs absorbs sub-millisecond jitter on
// already-fast metrics so they don't false-positive.
import { readFileSync, writeFileSync } from 'node:fs'
const DEFAULT_TOLERANCE = { tolFrac: 0.25, tolAbs: 1 }
export function loadBaseline(path) {
try {
return JSON.parse(readFileSync(path, 'utf8'))
} catch {
return { _meta: {}, scenarios: {} }
}
}
/**
* Compare a scenario's measured metrics against the baseline.
* @returns {{ rows: Array, regressed: boolean }}
*/
export function compareScenario(name, measured, baseline) {
const base = baseline.scenarios?.[name]
const tol = { ...DEFAULT_TOLERANCE, ...(base?.tolerance ?? {}) }
const rows = []
let regressed = false
for (const [metric, value] of Object.entries(measured)) {
if (typeof value !== 'number') {
continue
}
const baseValue = base?.metrics?.[metric]
if (typeof baseValue !== 'number') {
rows.push({ metric, measured: value, baseline: null, limit: null, status: 'new' })
continue
}
const limit = baseValue * (1 + tol.tolFrac) + tol.tolAbs
const over = value > limit
regressed = regressed || over
rows.push({
metric,
measured: value,
baseline: baseValue,
limit: Math.round(limit * 100) / 100,
deltaPct: baseValue ? Math.round(((value - baseValue) / baseValue) * 1000) / 10 : null,
status: over ? 'REGRESSED' : 'ok'
})
}
return { rows, regressed }
}
/** Write measured metrics back as the new baseline for the given scenarios. */
export function updateBaseline(path, results) {
const baseline = loadBaseline(path)
baseline.scenarios ??= {}
for (const { name, metrics } of results) {
const numeric = Object.fromEntries(Object.entries(metrics).filter(([, v]) => typeof v === 'number'))
const prev = baseline.scenarios[name] ?? {}
baseline.scenarios[name] = { ...prev, metrics: numeric }
}
baseline._meta = {
...baseline._meta,
updated: new Date().toISOString(),
platform: `${process.platform}-${process.arch}`,
node: process.version
}
writeFileSync(path, `${JSON.stringify(baseline, null, 2)}\n`)
}
export { DEFAULT_TOLERANCE }
+202
View File
@@ -0,0 +1,202 @@
// The one Chrome DevTools Protocol client for the desktop perf harness.
//
// Before this, every measure-*/profile-* script shipped its own copy-pasted
// `CDP` class (four subtly different implementations), its own `/json` vs
// `/json/list` target discovery, and its own Profiler ranking. Scenarios now
// import from here so there is a single place to fix a protocol bug.
const DEFAULT_PORT = 9222
// Stable DOM hooks the renderer exposes. Centralised so a component refactor
// updates one constant instead of a dozen scattered querySelector strings.
export const SELECTORS = {
composer: '[data-slot="composer-rich-input"]',
threadViewport: '[data-slot="aui_thread-viewport"]',
threadContent: '[data-slot="aui_thread-content"]',
assistantMessage: '[data-slot="aui_assistant-message-root"]',
turnPair: '[data-slot="aui_turn-pair"]',
profileRail: '[data-slot="profile-rail"]',
rowButton: '[data-slot="row-button"]'
}
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms))
/**
* Poll the CDP HTTP endpoint until a page target is available.
* @param {object} [opts]
* @param {number} [opts.port] remote-debugging-port (default 9222).
* @param {string} [opts.match] substring the target URL must contain (e.g. a dev-server port).
* @param {number} [opts.timeoutMs] how long to wait for a target.
*/
export async function discoverTarget({ port = DEFAULT_PORT, match, timeoutMs = 30000 } = {}) {
const deadline = Date.now() + timeoutMs
for (;;) {
try {
const list = await (await fetch(`http://127.0.0.1:${port}/json/list`)).json()
const pages = list.filter(t => t.type === 'page' && typeof t.webSocketDebuggerUrl === 'string')
const target = match
? pages.find(t => String(t.url).includes(match))
: pages.find(t => String(t.url).startsWith('http')) ?? pages[0]
if (target) {
return target
}
} catch {
// debug port not up yet — keep polling until the deadline.
}
if (Date.now() >= deadline) {
throw new Error(`no CDP page target on :${port}${match ? ` matching "${match}"` : ''} within ${timeoutMs}ms`)
}
await sleep(250)
}
}
export class CDP {
constructor(ws) {
this.ws = ws
this.id = 0
this.pending = new Map()
this.listeners = new Map()
}
static async open(url) {
const ws = new WebSocket(url)
await new Promise((resolve, reject) => {
ws.addEventListener('open', resolve, { once: true })
ws.addEventListener('error', reject, { once: true })
})
const cdp = new CDP(ws)
ws.addEventListener('message', ev => {
const m = JSON.parse(typeof ev.data === 'string' ? ev.data : ev.data.toString('utf8'))
if (m.id != null && cdp.pending.has(m.id)) {
const { resolve, reject } = cdp.pending.get(m.id)
cdp.pending.delete(m.id)
if (m.error) {
reject(new Error(m.error.message))
} else {
resolve(m.result)
}
} else if (m.method) {
for (const handler of cdp.listeners.get(m.method) ?? []) {
handler(m.params)
}
}
})
ws.addEventListener('close', () => {
for (const { reject } of cdp.pending.values()) {
reject(new Error('CDP socket closed'))
}
cdp.pending.clear()
})
return cdp
}
/** Connect straight to a discovered target. */
static async connect(opts) {
const target = await discoverTarget(opts)
return CDP.open(target.webSocketDebuggerUrl)
}
send(method, params = {}) {
const id = ++this.id
return new Promise((resolve, reject) => {
this.pending.set(id, { resolve, reject })
this.ws.send(JSON.stringify({ id, method, params }))
})
}
on(method, handler) {
if (!this.listeners.has(method)) {
this.listeners.set(method, [])
}
this.listeners.get(method).push(handler)
}
/** Evaluate an expression in the page and return its value (awaits promises). */
async eval(expression) {
const r = await this.send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true })
if (r.exceptionDetails) {
throw new Error(r.exceptionDetails.exception?.description || r.exceptionDetails.text || 'eval failed')
}
return r.result.value
}
close() {
this.ws.close()
}
}
/** Assert the renderer has the dev-only `__PERF_DRIVE__` harness attached. */
export async function requireDriver(cdp) {
const ok = await cdp.eval('!!(window.__PERF_DRIVE__ && window.__PERF_DRIVE__.stream)')
if (!ok) {
throw new Error(
'__PERF_DRIVE__ not on window. The perf harness needs a DEV renderer ' +
'(perf-probe.tsx is excluded from production builds). Launch with `npm run perf:serve`.'
)
}
}
/** Type real key events into the composer, one char at a time, at `cps` chars/sec. */
export async function typeIntoComposer(cdp, text, { cps = 15 } = {}) {
await cdp.eval(`(() => {
const el = document.querySelector(${JSON.stringify(SELECTORS.composer)})
if (!el) return false
el.focus()
const range = document.createRange()
range.selectNodeContents(el)
range.collapse(false)
const sel = window.getSelection()
sel.removeAllRanges()
sel.addRange(range)
return true
})()`)
const intervalMs = Math.max(1, Math.round(1000 / cps))
for (const ch of text) {
await cdp.send('Input.dispatchKeyEvent', { type: 'char', text: ch, unmodifiedText: ch })
await sleep(intervalMs)
}
}
/**
* Run `body()` while a V8 CPU profile is recording. Returns
* `{ result, profile }`; the caller decides whether to write the .cpuprofile.
*/
export async function withCpuProfile(cdp, body, { samplingIntervalUs = 100 } = {}) {
await cdp.send('Profiler.enable')
await cdp.send('Profiler.setSamplingInterval', { interval: samplingIntervalUs })
await cdp.send('Profiler.start')
let result
let stopped
try {
result = await body()
} finally {
// Always stop so a scenario error can't leave the profiler running.
stopped = await cdp.send('Profiler.stop')
}
return { result, profile: stopped.profile }
}
export { sleep }
+423
View File
@@ -0,0 +1,423 @@
// Connect the harness to a renderer — either an already-running debug instance
// (`attach`) or a freshly spawned, fully isolated one (`startIsolatedInstance`).
//
// The isolated instance is what makes the harness self-contained and unblocks
// the measurement that the single-instance lock used to prevent:
// · its own --user-data-dir → its own Electron single-instance lock, so it
// never collides with (or steals focus from) the user's running `hgui`.
// · its own HERMES_HOME → its own backend + sessions, no shared state.
// · its own --remote-debugging-port → a private CDP endpoint.
// · HERMES_DESKTOP_BOOT_FAKE=1 → deterministic boot overlay.
// The synthetic scenarios drive `$messages` directly, so no LLM credits are
// spent regardless of the isolated backend.
import { spawn } from 'node:child_process'
import { copyFileSync, existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
import { createRequire } from 'node:module'
import { homedir, tmpdir } from 'node:os'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { CDP, requireDriver, sleep } from './cdp.mjs'
const require = createRequire(import.meta.url)
const DESKTOP_DIR = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', '..')
async function reachable(url) {
try {
await fetch(url)
return true
} catch {
return false
}
}
async function waitFor(fn, { timeoutMs, label }) {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
if (await fn()) {
return
}
await sleep(300)
}
throw new Error(`timed out after ${timeoutMs}ms waiting for ${label}`)
}
// Seed an isolated HERMES_HOME with just enough config (NOT sessions) so the
// spawned instance reaches an empty chat view instead of the onboarding wizard.
// A separate HERMES_HOME dir means a separate gateway lock — no collision with
// the user's running app, which keeps its own sessions DB and state.
function seedConfigFrom(sourceHome, targetHome) {
if (!existsSync(sourceHome)) {
return
}
for (const name of ['config.yaml', '.env', 'auth.json']) {
const from = join(sourceHome, name)
if (existsSync(from)) {
try {
copyFileSync(from, join(targetHome, name))
} catch {
// best-effort — a missing file just means onboarding may appear.
}
}
}
}
// Resolve the vite CLI entry via its package.json `bin` (Vite 8's `exports`
// blocks importing `vite/bin/vite.js` directly).
function resolveViteBin() {
const pkgPath = require.resolve('vite/package.json')
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
const rel = typeof pkg.bin === 'string' ? pkg.bin : pkg.bin?.vite
if (!rel) {
throw new Error('could not resolve the vite CLI from vite/package.json')
}
return join(dirname(pkgPath), rel)
}
// Poll the perf driver's `connected()` until the gateway socket is open.
// Returns false if the probe predates this helper or the timeout elapses.
async function waitForConnected(cdp, timeoutMs) {
const hasProbe = await cdp.eval('typeof window.__PERF_DRIVE__.connected === "function"')
if (!hasProbe) {
return false
}
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
if (await cdp.eval('window.__PERF_DRIVE__.connected()')) {
return true
}
await sleep(500)
}
return false
}
function runProcess(command, args, { env } = {}) {
return new Promise((resolveRun, reject) => {
const child = spawn(command, args, {
cwd: DESKTOP_DIR,
stdio: 'inherit',
env: env ? { ...process.env, ...env } : process.env
})
child.on('error', reject)
child.on('exit', code => (code === 0 ? resolveRun() : reject(new Error(`${command} ${args[0]} exited ${code}`))))
})
}
function runNode(scriptRelPath, args = []) {
return runProcess(process.execPath, [join(DESKTOP_DIR, scriptRelPath), ...args])
}
// Build a production renderer WITH the perf probe included (VITE_PERF_PROBE=1),
// plus the prod electron-main bundle, so the harness can measure a real,
// minified React build instead of the ~3x-slower dev build. Slow (a full vite
// build); do it once, then run/attach many times.
export async function buildProdRenderer() {
const viteBin = resolveViteBin()
await runProcess(process.execPath, [viteBin, 'build'], { env: { VITE_PERF_PROBE: '1' } })
await runNode('scripts/bundle-electron-main.mjs')
}
/** Attach to a renderer already listening on `port` (launched via perf:serve or with --remote-debugging-port). */
export async function attach({ port = 9222, match } = {}) {
const cdp = await CDP.connect({ port, match })
await requireDriver(cdp)
return { cdp, teardown: () => cdp.close() }
}
/**
* Spawn an isolated dev instance (vite + electron), wait for the perf driver,
* and return `{ cdp, teardown, devUrl, port }`. `teardown` kills both children
* and removes any temp dirs it created.
*/
// Chromium switches that stop frame-production throttling for a window that
// isn't foregrounded (the perf window usually sits behind the IDE/terminal).
const ANTI_THROTTLE_FLAGS = [
'--disable-background-timer-throttling',
'--disable-renderer-backgrounding',
'--disable-backgrounding-occluded-windows',
'--disable-features=CalculateNativeWinOcclusion'
]
/**
* Spawn an isolated instance and connect the perf driver. Two render modes:
* · dev (default): vite dev server + dev electron-main bundle.
* · prod (`prod: true`): a production build (call buildProdRenderer first);
* electron loads dist/index.html — representative, minified React.
* `coldStart: true` skips the gateway-connect wait and settle (for launch-time
* measurement) and returns `timings` (spawn→CDP, spawn→driver) plus renderer
* boot marks (FCP, time-to-composer).
*/
export async function startIsolatedInstance({
port = 9222,
devPort = 5174,
prod = false,
coldStart = false,
hermesHome,
userDataDir,
seedConfig = true,
settleMs = 2500,
connectTimeoutMs = 90000
} = {}) {
const children = []
const tempDirs = []
const mkTemp = prefix => {
const dir = mkdtempSync(join(tmpdir(), prefix))
tempDirs.push(dir)
return dir
}
const home = hermesHome ?? mkTemp('hermes-perf-home-')
const userData = userDataDir ?? mkTemp('hermes-perf-ud-')
const devUrl = prod ? null : `http://127.0.0.1:${devPort}`
if (seedConfig && !hermesHome) {
seedConfigFrom(join(homedir(), '.hermes'), home)
}
const teardown = () => {
for (const child of children) {
try {
child.kill('SIGTERM')
} catch {
// already gone
}
}
for (const dir of tempDirs) {
try {
rmSync(dir, { recursive: true, force: true })
} catch {
// best-effort
}
}
}
try {
if (prod) {
// Renderer + main are expected pre-built (buildProdRenderer). Cheap to
// re-bundle main so an isolated run always matches current source.
await runNode('scripts/bundle-electron-main.mjs')
} else {
if (!(await reachable(devUrl))) {
const viteBin = resolveViteBin()
const vite = spawn(process.execPath, [viteBin, '--host', '127.0.0.1', '--port', String(devPort)], {
cwd: DESKTOP_DIR,
stdio: ['ignore', 'inherit', 'inherit']
})
children.push(vite)
await waitFor(() => reachable(devUrl), { timeoutMs: 60000, label: `vite dev server on :${devPort}` })
}
await runNode('scripts/bundle-electron-main.mjs', ['--dev'])
}
// Isolated Electron: own --user-data-dir (single-instance lock scope) + own
// HERMES_HOME (backend + sessions). No DEV_SERVER env in prod → dist load.
const electronBin = require('electron')
// NB: do NOT set HERMES_DESKTOP_BOOT_FAKE here — it injects artificial
// per-phase sleeps into the boot overlay, which inflates cold-start timing
// (and adds pointless startup latency to the steady-state runs). We want the
// real boot sequence.
const env = {
...process.env,
HERMES_HOME: home,
// The app's dev-CDP resolver (electron/dev-cdp.ts) appends its own
// remote-debugging-port switch AFTER argv, so on a non-default --port the
// Chromium flag below loses and the instance binds 9222 anyway. The env
// override is the supported knob — set it so --port actually wins.
HERMES_DESKTOP_CDP_PORT: String(port),
XCURSOR_SIZE: '24'
}
if (devUrl) {
env.HERMES_DESKTOP_DEV_SERVER = devUrl
}
const spawnAt = Date.now()
const electron = spawn(
electronBin,
['.', `--user-data-dir=${userData}`, `--remote-debugging-port=${port}`, ...ANTI_THROTTLE_FLAGS],
{ cwd: DESKTOP_DIR, stdio: ['ignore', 'inherit', 'inherit'], env }
)
children.push(electron)
// Wait for the renderer + perf driver. In prod the target URL is file://,
// so don't match on the dev port.
let cdp = null
let cdpAt = 0
await waitFor(
async () => {
try {
cdp = await CDP.connect({ port, match: devUrl ? String(devPort) : undefined, timeoutMs: 2000 })
cdpAt = cdpAt || Date.now()
return await cdp.eval('!!(window.__PERF_DRIVE__ && window.__PERF_DRIVE__.stream)')
} catch {
if (cdp) {
cdp.close()
cdp = null
}
return false
}
},
{ timeoutMs: 120000, label: 'isolated renderer + __PERF_DRIVE__' }
)
const driverAt = Date.now()
try {
await cdp.send('Emulation.setFocusEmulationEnabled', { enabled: true })
} catch {
// Older CDP / not supported — fall back to the anti-throttle flags.
}
// Renderer-side boot marks (relative to its own navigation start).
const bootMarks = await readBootMarks(cdp)
const timings = {
spawn_to_cdp_ms: cdpAt ? cdpAt - spawnAt : null,
spawn_to_driver_ms: driverAt - spawnAt,
...bootMarks
}
let connected = true
if (!coldStart) {
// Steady-state scenarios: wait for the gateway to connect (reconnect churn
// contaminates frame pacing) and let residual cold-start work drain.
connected = await waitForConnected(cdp, connectTimeoutMs)
if (!connected) {
console.warn(
`[perf] gateway did not connect within ${connectTimeoutMs}ms — ` +
'stream/frame numbers may be inflated by reconnect churn.'
)
}
await sleep(settleMs)
}
return {
connected,
cdp,
devUrl,
port,
prod,
timings,
teardown: () => {
cdp?.close()
teardown()
}
}
} catch (err) {
teardown()
throw err
}
}
// Representative cold-start sampling. A fresh --user-data-dir means a COLD V8
// code cache and worst-case bundle recompile every run (~+400ms measured); real
// users reuse their profile, so a warm cache is the representative case. We reuse
// ONE profile across runs: run 0 warms the cache (discarded), runs 1..N are the
// warm samples. Each run steps the port so a just-killed instance can't be
// re-attached, and we pause between runs so the single-instance lock releases.
export async function coldStartSamples({ runs = 3, port = 9222, devPort = 5174, prod = false, warm = true } = {}) {
const pickNumeric = timings => Object.fromEntries(Object.entries(timings).filter(([, v]) => typeof v === 'number'))
const samples = []
if (warm) {
// Shared profile across runs: run 0 warms the V8 code cache (discarded),
// runs 1..N are the representative warm samples.
const home = mkdtempSync(join(tmpdir(), 'hermes-perf-cold-home-'))
const userDataDir = mkdtempSync(join(tmpdir(), 'hermes-perf-cold-ud-'))
seedConfigFrom(join(homedir(), '.hermes'), home)
try {
for (let i = 0; i <= runs; i++) {
const inst = await startIsolatedInstance({
port: port + i,
devPort: devPort + i,
prod,
coldStart: true,
hermesHome: home,
userDataDir,
seedConfig: false
})
if (i > 0) {
samples.push(pickNumeric(inst.timings))
}
inst.teardown()
await sleep(2500) // let the single-instance lock release before reuse
}
} finally {
for (const dir of [home, userDataDir]) {
try {
rmSync(dir, { recursive: true, force: true })
} catch {
// best-effort
}
}
}
} else {
// Worst case: a fresh profile per run → cold code cache every launch
// (first-launch-after-install). startIsolatedInstance makes+removes its dirs.
for (let i = 0; i < runs; i++) {
const inst = await startIsolatedInstance({ port: port + i, devPort: devPort + i, prod, coldStart: true })
samples.push(pickNumeric(inst.timings))
inst.teardown()
await sleep(2500)
}
}
return samples
}
// Read First Contentful Paint + time-to-composer from the renderer, relative to
// its navigation start (the process-spawn deltas live in `timings`).
async function readBootMarks(cdp) {
try {
return await cdp.eval(`(() => {
const paints = performance.getEntriesByType('paint')
const fcp = paints.find(p => p.name === 'first-contentful-paint')
const nav = performance.getEntriesByType('navigation')[0]
const composer = document.querySelector('[data-slot="composer-rich-input"]')
// Largest script resource ≈ the (intentionally single) renderer bundle.
// responseEnd → the script's own decode; the eval cost shows up as the gap
// between the bundle's responseEnd and domInteractive.
const scripts = performance.getEntriesByType('resource').filter(r => r.initiatorType === 'script')
const mainScript = scripts.sort((a, b) => (b.encodedBodySize || 0) - (a.encodedBodySize || 0))[0]
const round = n => (typeof n === 'number' ? Math.round(n) : null)
return {
fcp_ms: fcp ? round(fcp.startTime) : null,
dom_interactive_ms: nav ? round(nav.domInteractive) : null,
dom_content_loaded_ms: nav ? round(nav.domContentLoadedEventEnd) : null,
main_script_kb: mainScript ? round((mainScript.encodedBodySize || 0) / 1024) : null,
main_script_response_end_ms: mainScript ? round(mainScript.responseEnd) : null,
nav_to_read_ms: round(performance.now()),
composer_present: !!composer
}
})()`)
} catch {
return { fcp_ms: null, dom_interactive_ms: null, composer_present: false }
}
}
export { DESKTOP_DIR }
+89
View File
@@ -0,0 +1,89 @@
// Shared numeric helpers for perf scenarios. Every measure-*/profile-* script
// used to carry its own copy of these.
/** Nearest-rank percentile over an UNSORTED array. p in [0,1]. */
export function percentile(values, p) {
if (!values.length) {
return 0
}
const sorted = [...values].sort((a, b) => a - b)
const idx = Math.min(sorted.length - 1, Math.floor(sorted.length * p))
return sorted[idx]
}
/** min/p50/p90/p95/p99/max/mean over a sample array (rounded to 2dp). */
export function summarize(values) {
const round = n => Math.round(n * 100) / 100
if (!values.length) {
return { n: 0, min: 0, p50: 0, p90: 0, p95: 0, p99: 0, max: 0, mean: 0 }
}
const sorted = [...values].sort((a, b) => a - b)
const mean = values.reduce((a, b) => a + b, 0) / values.length
return {
n: values.length,
min: round(sorted[0]),
p50: round(percentile(sorted, 0.5)),
p90: round(percentile(sorted, 0.9)),
p95: round(percentile(sorted, 0.95)),
p99: round(percentile(sorted, 0.99)),
max: round(sorted[sorted.length - 1]),
mean: round(mean)
}
}
/** Median of a numeric array (used to reduce N repeated runs to one number). */
export function median(values) {
return percentile(values, 0.5)
}
/** Frame-interval histogram matching the buckets the stream scripts reported. */
export function frameHistogram(frames) {
const buckets = { '<=16.7': 0, '16.7-33': 0, '33-50': 0, '50-100': 0, '100-200': 0, '>200': 0 }
for (const f of frames) {
if (f <= 16.7) buckets['<=16.7']++
else if (f <= 33) buckets['16.7-33']++
else if (f <= 50) buckets['33-50']++
else if (f <= 100) buckets['50-100']++
else if (f <= 200) buckets['100-200']++
else buckets['>200']++
}
return buckets
}
/**
* Rank functions by self-time from a V8 CPU profile (Profiler.stop output).
* Returns the top `limit` entries as { ms, name, url, line }.
*/
export function cpuProfileTopSelf(profile, limit = 30) {
const samples = profile.samples || []
const timeDeltas = profile.timeDeltas || []
const nodes = new Map(profile.nodes.map(n => [n.id, n]))
const selfUs = new Map()
for (let i = 0; i < samples.length; i++) {
const id = samples[i]
selfUs.set(id, (selfUs.get(id) || 0) + (timeDeltas[i] ?? 0))
}
return [...selfUs.entries()]
.map(([id, us]) => {
const cf = nodes.get(id)?.callFrame || {}
return {
ms: us / 1000,
name: cf.functionName || '(anonymous)',
url: String(cf.url || '').slice(-70),
line: cf.lineNumber
}
})
.filter(x => !/\(root\)|\(idle\)|\(garbage collector\)|\(program\)/.test(x.name))
.sort((a, b) => b.ms - a.ms)
.slice(0, limit)
}
+221
View File
@@ -0,0 +1,221 @@
// Desktop perf harness entrypoint.
//
// node scripts/perf/run.mjs [scenarios...] [flags]
//
// Default (no scenarios): runs the CI suite (stream, keystroke, transcript)
// against a renderer on :9222 and diffs the committed baseline.
//
// Flags:
// --spawn launch a fully isolated instance (own user-data-dir +
// HERMES_HOME + debug port) instead of attaching
// --port <n> CDP port to attach to (default 9222)
// --dev-port <n> vite dev-server port to match / spawn (default 5174)
// --runs <n> repeat each scenario n times, report the median (default 1)
// --cpuprofile [dir] also record a V8 CPU profile per scenario (top-30 self time)
// --update-baseline overwrite baseline.json with this run's numbers
// --json <path> write the full results JSON here
// --tier <ci|backend> run all scenarios of a tier
// ...scenario opts e.g. --tokens 600, --turns 400, --real, --a <sid> --b <sid>, --profile <name>
//
// Examples:
// npm run perf # attach to :9222, run CI suite, gate on baseline
// npm run perf -- --spawn # isolated instance, no running app needed
// npm run perf -- stream --cpuprofile --tokens 800
// npm run perf -- --update-baseline
import { writeFileSync } from 'node:fs'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { withCpuProfile } from './lib/cdp.mjs'
import { compareScenario, loadBaseline, updateBaseline } from './lib/baseline.mjs'
import { attach, buildProdRenderer, coldStartSamples, startIsolatedInstance } from './lib/launch.mjs'
import { cpuProfileTopSelf, median } from './lib/stats.mjs'
import { CI_SCENARIOS, SCENARIOS } from './scenarios/index.mjs'
const HERE = dirname(fileURLToPath(import.meta.url))
const BASELINE_PATH = join(HERE, 'baseline.json')
function parseArgs(argv) {
const positional = []
const flags = {}
for (let i = 0; i < argv.length; i++) {
const arg = argv[i]
if (arg.startsWith('--')) {
const [key, inlineValue] = arg.slice(2).split(/=(.*)/s)
const next = argv[i + 1]
if (inlineValue !== undefined) {
flags[key] = inlineValue
} else if (next === undefined || next.startsWith('--')) {
flags[key] = true
} else {
flags[key] = next
i++
}
} else {
positional.push(arg)
}
}
return { positional, flags }
}
function medianMetrics(runs) {
const keys = new Set(runs.flatMap(r => Object.keys(r)))
const out = {}
for (const key of keys) {
const values = runs.map(r => r[key]).filter(v => typeof v === 'number')
out[key] = values.length ? Math.round(median(values) * 10) / 10 : runs[0][key]
}
return out
}
function printMetrics(name, metrics, comparison) {
console.log(`\n${name}`)
const byMetric = new Map((comparison?.rows ?? []).map(r => [r.metric, r]))
for (const [metric, value] of Object.entries(metrics)) {
const row = byMetric.get(metric)
if (!row || row.baseline === null) {
console.log(` ${metric.padEnd(26)} ${String(value).padStart(9)}${row ? ' (new)' : ''}`)
} else {
const tag = row.status === 'REGRESSED' ? ' ✗ REGRESSED' : ' ✓'
const delta = row.deltaPct === null ? '' : ` (${row.deltaPct > 0 ? '+' : ''}${row.deltaPct}%)`
console.log(
` ${metric.padEnd(26)} ${String(value).padStart(9)} vs ${String(row.baseline).padStart(9)}${delta}${tag}`
)
}
}
}
async function main() {
const { positional, flags } = parseArgs(process.argv.slice(2))
let names = positional
if (!names.length) {
names = flags.tier ? Object.values(SCENARIOS).filter(s => s.tier === flags.tier).map(s => s.name) : CI_SCENARIOS
}
const unknown = names.filter(n => !SCENARIOS[n])
if (unknown.length) {
console.error(`unknown scenario(s): ${unknown.join(', ')}\nknown: ${Object.keys(SCENARIOS).join(', ')}`)
process.exit(2)
}
const runs = Number(flags.runs ?? 1)
const port = Number(flags.port ?? 9222)
const devPort = Number(flags['dev-port'] ?? 5174)
const prod = 'prod' in flags
const cpuProfile = 'cpuprofile' in flags
const cpuProfileDir = typeof flags.cpuprofile === 'string' ? flags.cpuprofile : HERE
const coldNames = names.filter(n => SCENARIOS[n].tier === 'cold')
const liveNames = names.filter(n => SCENARIOS[n].tier !== 'cold')
// ci + cold metrics are stable enough to gate against the baseline; backend
// scenarios vary too much with the live environment, so they're report-only.
const GATED = new Set(['ci', 'cold'])
const baseline = loadBaseline(BASELINE_PATH)
const results = []
let regressed = false
const record = (name, tier, metrics, detail) => {
const comparison = GATED.has(tier) ? compareScenario(name, metrics, baseline) : null
regressed = regressed || Boolean(comparison?.regressed)
results.push({ name, tier, metrics, detail })
printMetrics(name, metrics, comparison)
}
if (prod) {
if (!flags.spawn) {
console.error('--prod requires --spawn (it builds and launches an isolated production renderer)')
process.exit(2)
}
console.log('[perf] building production renderer with the probe (VITE_PERF_PROBE=1)…')
await buildProdRenderer()
}
// Cold start measures the launch itself → a fresh spawn per run.
if (coldNames.length) {
if (!flags.spawn) {
console.error('cold-start requires --spawn (it measures a fresh launch)')
process.exit(2)
}
// Representative WARM-cache samples (see coldStartSamples). Pass --cold-fresh
// to instead measure the worst-case first-launch (cold code cache).
const perRun = await coldStartSamples({ runs, port, devPort, prod, warm: !('cold-fresh' in flags) })
record('cold-start', 'cold', medianMetrics(perRun), { runs, warm: !('cold-fresh' in flags) })
}
// Steady-state scenarios share one persistent connection.
if (liveNames.length) {
const connection = flags.spawn
? await startIsolatedInstance({ port, devPort, prod })
: await attach({ port, match: prod ? undefined : String(devPort) })
const { cdp, teardown } = connection
try {
for (const name of liveNames) {
const scenario = SCENARIOS[name]
const perRun = []
let detail = null
for (let i = 0; i < runs; i++) {
if (cpuProfile && i === 0) {
const { result, profile } = await withCpuProfile(cdp, () => scenario.run(cdp, flags))
const out = join(cpuProfileDir, `${name}-${Date.now()}.cpuprofile`)
writeFileSync(out, JSON.stringify(profile))
console.log(`\n[cpuprofile] wrote ${out}`)
console.log('[cpuprofile] top self-time (ms):')
for (const r of cpuProfileTopSelf(profile, 15)) {
console.log(` ${r.ms.toFixed(1).padStart(7)} ${r.name.padEnd(38)} ${r.url}:${r.line}`)
}
perRun.push(result.metrics)
detail = result.detail
} else {
const result = await scenario.run(cdp, flags)
perRun.push(result.metrics)
detail = result.detail
}
}
record(name, scenario.tier, medianMetrics(perRun), detail)
}
} finally {
teardown()
}
}
if (flags.json) {
writeFileSync(resolve(String(flags.json)), `${JSON.stringify({ timestamp: new Date().toISOString(), results }, null, 2)}\n`)
console.log(`\nwrote ${flags.json}`)
}
if (flags['update-baseline']) {
updateBaseline(BASELINE_PATH, results.filter(r => GATED.has(r.tier)))
console.log(`\nupdated ${BASELINE_PATH}`)
return
}
if (regressed) {
console.error('\n✗ perf regression vs baseline (see REGRESSED rows above)')
process.exit(1)
}
console.log('\n✓ no perf regressions')
}
main().catch(err => {
console.error('\nperf harness failed:', err.stack ?? err.message)
process.exit(1)
})
@@ -0,0 +1,18 @@
// Cold start — launch → renderer → interactive. Unlike the other scenarios this
// measures the LAUNCH itself, so it can't run against an already-up instance:
// the runner spawns a fresh isolated instance per run (requires --spawn) and
// reads the timings/boot-marks the launcher captures. Registered here so it's a
// known name with a baseline entry; the actual measurement lives in run.mjs.
//
// Metrics (lower is better):
// spawn_to_cdp_ms process spawn → CDP page target reachable (electron/V8 up)
// spawn_to_driver_ms process spawn → renderer mounted + perf driver present
// fcp_ms renderer nav start → first contentful paint
export default {
name: 'cold-start',
tier: 'cold',
description: 'Launch → first paint → interactive (fresh spawn per run).',
run() {
throw new Error('cold-start is measured by the runner via fresh spawns; use `--spawn`.')
}
}
@@ -0,0 +1,84 @@
// Time-to-first-token — Enter → first assistant token painted. The latency an
// agent app is uniquely judged on, spanning the desktop submit path AND the
// backend/agent-loop first-token time. Backend tier: fires a REAL prompt, needs
// a live backend (and credits). Report-only.
//
// node scripts/perf/run.mjs first-token --spawn --prompt "hi"
import { SELECTORS, sleep, typeIntoComposer } from '../lib/cdp.mjs'
import { summarize } from '../lib/stats.mjs'
export default {
name: 'first-token',
tier: 'backend',
description: 'Enter → first assistant token painted (real backend).',
async run(cdp, opts = {}) {
const rounds = Number(opts.rounds ?? 3)
const prompt = opts.prompt ?? 'reply with a single short sentence'
const timeoutMs = Number(opts.timeoutMs ?? 60000)
await cdp.send('Runtime.enable')
const firstTokens = []
for (let i = 0; i < rounds; i++) {
const baseText = await cdp.eval(`(() => {
const a = document.querySelectorAll(${JSON.stringify(SELECTORS.assistantMessage)})
return a.length ? a[a.length - 1].textContent.length : 0
})()`)
const baseCount = await cdp.eval(`document.querySelectorAll(${JSON.stringify(SELECTORS.assistantMessage)}).length`)
await typeIntoComposer(cdp, `${prompt} (${i})`, { cps: 60 })
const submitAt = Date.now()
await cdp.eval(`(() => {
const el = document.querySelector(${JSON.stringify(SELECTORS.composer)})
el && el.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', bubbles: true, cancelable: true }))
})()`)
const deadline = Date.now() + timeoutMs
let firstTokenMs = null
while (Date.now() < deadline) {
await sleep(25)
const grown = await cdp.eval(`(() => {
const a = document.querySelectorAll(${JSON.stringify(SELECTORS.assistantMessage)})
if (a.length > ${baseCount}) return true
return a.length ? a[a.length - 1].textContent.length > ${baseText} : false
})()`)
if (grown) {
firstTokenMs = Date.now() - submitAt
break
}
}
if (firstTokenMs !== null) {
firstTokens.push(firstTokenMs)
}
// Let the turn finish before the next round.
const turnDeadline = Date.now() + timeoutMs
while (Date.now() < turnDeadline) {
await sleep(250)
const busy = await cdp.eval(`!!document.querySelector('[data-status="running"], [data-busy="true"]')`)
if (!busy) {
break
}
}
await sleep(500)
}
if (!firstTokens.length) {
throw new Error('no first token observed — is a backend with credits connected?')
}
const s = summarize(firstTokens)
return {
metrics: { first_token_p50_ms: s.p50, first_token_p95_ms: s.p95 },
detail: { rounds, samples: firstTokens, summary: s }
}
}
}
@@ -0,0 +1,284 @@
// What does the app cost while a turn is running but NOTHING is arriving?
//
// The user-visible symptom: with a thread spinning, resizing the sidebar or
// typing in the composer feels slow. That is not streaming cost — the stream
// is idle. It is the app re-rendering on its own, competing with the
// interaction for the main thread.
//
// This scenario holds N tiles in a busy state, pushes NO tokens, and measures:
// - idle_commits_per_s the renderer's self-inflicted commit rate
// - drag_fps fps while dragging the sidebar splitter
// - type_fps fps while typing in the composer
//
// A perfectly idle app scores 0 idle commits and pins both interactions at the
// display's refresh rate. Every idle commit is main-thread time stolen from an
// interaction the user can feel.
//
// node scripts/perf/run.mjs idle-cost --spawn [--tiles 5] [--seconds 6]
import { sleep } from '../lib/cdp.mjs'
/** Seed `tiles` busy session tiles. Same publish path as `multitab` /
* `render-churn`, but the driver never runs — the turn just stays open. */
const setup = (tiles, seedTurns) => `
(() => {
const hook = window.__HERMES_SESSION_TILES__
if (!hook) return 'no-hook'
if (!window.__RENDER_COUNTS__) return 'no-render-counter'
const turn = (sid, i) => ([
{ id: sid + '-u' + i, role: 'user', timestamp: Date.now(),
parts: [{ type: 'text', text: 'Question ' + i + ' about the diff.' }] },
{ id: sid + '-a' + i, role: 'assistant', timestamp: Date.now(), pending: false,
parts: [{ type: 'text', text: '## Finding ' + i + '\\n\\nThe handler swallows the rejection.\\n\\n- Point one.\\n- Point two.\\n' }] }
])
window.__IDLE__ = { ids: [] }
for (let n = 1; n <= ${tiles}; n++) {
const sid = 'idle-tile-' + n
const rid = 'idle-rt-' + n
const messages = []
for (let i = 0; i < ${seedTurns}; i++) messages.push(...turn(sid, i))
// An OPEN assistant message: the turn is running, but no tokens arrive.
messages.push({ id: sid + '-stream', role: 'assistant', timestamp: Date.now(), pending: true,
parts: [{ type: 'text', text: 'Working on it.' }] })
window.__IDLE__.ids.push({ sid, rid })
hook.open(sid, 'center')
hook.patch(sid, { runtimeId: rid })
hook.publish(rid, {
storedSessionId: sid, messages, branch: '', cwd: '', model: '', provider: '',
reasoningEffort: '', serviceTier: '', fast: false, yolo: false, personality: '',
busy: true, awaitingResponse: false, streamId: sid + '-stream', sawAssistantPayload: true,
pendingBranchGroup: null, interrupted: false, interimBoundaryPending: false,
needsInput: false, turnStartedAt: Date.now(), usage: null
})
}
return 'ok'
})()
`
const reveal = sid => `window.__HERMES_LAYOUT_TREE__.reveal(${JSON.stringify(`session-tile:${sid}`)})`
/** Measure the app's self-inflicted commit rate with nothing happening. */
const idleCost = seconds => `
(async () => {
const rc = window.__RENDER_COUNTS__
rc.start()
const t0 = performance.now()
await new Promise(r => setTimeout(r, ${seconds} * 1000))
const elapsed = (performance.now() - t0) / 1000
rc.stop()
return JSON.stringify({
elapsed,
commits: rc.commits(),
top: rc.report(12),
owners: rc.report(300).filter(r => r.stateChanged > 0 && r.propsChanged === 0).slice(0, 10)
})
})()
`
/** Record frame pacing across an interaction driven from the page.
*
* The gesture body drives itself on requestAnimationFrame, so it IS the frame
* clock — timing is taken from those same callbacks rather than a second,
* independent rAF ticker. Running two rAF consumers made the observer's
* deltas count the driver's frames as well as the app's and reported ~3fps
* where the interaction actually ran at ~23fps. `frames` is filled by the
* body via `__MARK__`.
*
* `record` MUST be false for any fps number you intend to believe: the render
* counter walks the whole fiber tree on every commit, so recording during a
* gesture measures the instrumentation as much as the app. Attribution and
* timing therefore run as two separate passes. */
const withFrames = (body, record = false) => `
(async () => {
const rc = window.__RENDER_COUNTS__
${record ? 'rc.start()' : ''}
const frames = []
let last = performance.now()
// The body calls this once per frame it drives.
const __MARK__ = () => {
const now = performance.now()
frames.push(now - last)
last = now
}
${body}
${record ? 'rc.stop()' : ''}
const total = frames.reduce((a, b) => a + b, 0)
const sorted = [...frames].sort((a, b) => a - b)
const pct = p => sorted.length ? sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * p))] : 0
return JSON.stringify({
fps: total ? (frames.length / total) * 1000 : 0,
p95: pct(0.95),
worst: sorted.length ? sorted[sorted.length - 1] : 0,
slow33: frames.filter(f => f > 33).length,
n: frames.length,
commits: ${record ? 'rc.commits()' : '0'},
top: ${record ? 'rc.report(10)' : '[]'}
})
})()
`
/** Drag the sidebar splitter — the resize symptom.
* Sweeps monotonically (an oscillation nets to zero and can clamp to a no-op),
* and reports how far it actually moved so a drag that silently did nothing
* shows up as `dragMoved: 0` instead of a confident wrong number. */
const DRAG = withFrames(`
const handle = document.querySelector('[role="separator"]')
window.__DRAG_TARGET__ = handle ? 'separator' : 'none'
window.__DRAG_MOVED__ = 0
if (handle) {
const box = handle.getBoundingClientRect()
const y = box.top + box.height / 2
const x0 = box.left + box.width / 2
let x = x0
const opts = { bubbles: true, cancelable: true, pointerId: 1, pointerType: 'mouse', isPrimary: true, button: 0, buttons: 1 }
handle.dispatchEvent(new PointerEvent('pointerdown', { ...opts, clientX: x, clientY: y }))
// Out 60px then back — a real gesture, with a net displacement at the peak.
for (let i = 0; i < 30; i++) {
x += 2
window.dispatchEvent(new PointerEvent('pointermove', { ...opts, clientX: x, clientY: y }))
await new Promise(r => requestAnimationFrame(r))
__MARK__()
}
window.__DRAG_MOVED__ = Math.round(x - x0)
for (let i = 0; i < 30; i++) {
x -= 2
window.dispatchEvent(new PointerEvent('pointermove', { ...opts, clientX: x, clientY: y }))
await new Promise(r => requestAnimationFrame(r))
__MARK__()
}
window.dispatchEvent(new PointerEvent('pointerup', { ...opts, buttons: 0, clientX: x, clientY: y }))
} else {
await new Promise(r => setTimeout(r, 1000))
}
`)
/** Type into the composer — the keystroke symptom. */
const TYPE = withFrames(`
const el = document.querySelector('[contenteditable="true"], textarea')
window.__TYPE_TARGET__ = el ? (el.tagName.toLowerCase()) : 'none'
if (el) {
el.focus()
for (let i = 0; i < 40; i++) {
const ch = 'performance testing '[i % 20]
el.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: ch }))
if (el.tagName === 'TEXTAREA') {
el.value += ch
el.dispatchEvent(new Event('input', { bubbles: true }))
} else {
el.textContent += ch
el.dispatchEvent(new InputEvent('input', { bubbles: true, data: ch, inputType: 'insertText' }))
}
el.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, key: ch }))
// Wait for the frame this keystroke produces, then mark it — same clock
// discipline as DRAG, so typing fps is comparable to drag fps.
await new Promise(r => requestAnimationFrame(r))
__MARK__()
await new Promise(r => setTimeout(r, 25))
}
} else {
await new Promise(r => setTimeout(r, 1000))
}
`)
const CLEANUP = `
(() => {
if (window.__IDLE__) {
for (const { sid, rid } of window.__IDLE__.ids) {
const states = window.__HERMES_SESSION_TILES__.states()
window.__HERMES_SESSION_TILES__.publish(rid, { ...states[rid], busy: false, streamId: null })
window.__HERMES_SESSION_TILES__.close(sid)
}
window.__IDLE__ = null
}
window.__RENDER_COUNTS__.clear()
return 'cleaned'
})()
`
const round = (n, places = 1) => Math.round(n * 10 ** places) / 10 ** places
export default {
name: 'idle-cost',
// NOT 'ci': the drag fps this reports (~0.6fps, p95 814ms) contradicts a
// direct single-clock probe of the same gesture on the same build (57fps),
// and I could not reconcile the two — ruled out sash selection, tile setup,
// counter residue, and a 20s soak. Its RENDER attribution and idle commit
// rate are trustworthy and are what this scenario is for; the interaction
// fps is reported for investigation, not gated on, until that is explained.
tier: 'report',
description: 'Busy-but-silent tiles: idle commit rate, and fps while resizing / typing.',
async run(cdp, opts = {}) {
const tiles = Number(opts.tiles ?? 5)
const seedTurns = Number(opts.turns ?? 20)
const seconds = Number(opts.seconds ?? 6)
await cdp.send('Runtime.enable')
const ok = await cdp.eval(setup(tiles, seedTurns))
if (ok !== 'ok') {
throw new Error(`idle-cost setup failed (${ok}) — needs a dev renderer with src/debug installed.`)
}
for (let n = 1; n <= tiles; n++) {
await cdp.eval(reveal(`idle-tile-${n}`))
await sleep(300)
}
await sleep(1500)
const idle = JSON.parse(await cdp.eval(idleCost(seconds)))
const drag = JSON.parse(await cdp.eval(DRAG))
const dragTarget = await cdp.eval('window.__DRAG_TARGET__ || "unknown"')
const dragMoved = await cdp.eval('window.__DRAG_MOVED__ ?? 0')
const type = JSON.parse(await cdp.eval(TYPE))
const typeTarget = await cdp.eval('window.__TYPE_TARGET__ || "unknown"')
await cdp.eval(CLEANUP)
if (dragTarget === 'none') {
throw new Error('idle-cost: no [role="separator"] sash found — the drag measured nothing.')
}
if (typeTarget === 'none') {
throw new Error('idle-cost: no composer found — the typing pass measured nothing.')
}
return {
metrics: {
// Commits per second with a turn open and nothing arriving. Should be 0.
idle_commits_per_s: round(idle.commits / idle.elapsed),
idle_renders: idle.top.reduce((a, r) => a + r.renders, 0),
// Interaction smoothness while that churn competes for the main thread.
// Reported as a deficit from 60fps so "lower is better" matches the
// baseline gate's direction.
drag_fps_deficit: round(Math.max(0, 60 - drag.fps)),
drag_slow_frames: drag.slow33,
type_fps_deficit: round(Math.max(0, 60 - type.fps)),
type_slow_frames: type.slow33
},
detail: {
tiles,
dragTarget,
dragMoved,
idleSeconds: round(idle.elapsed),
dragFps: round(drag.fps),
dragP95: round(drag.p95),
dragWorst: round(drag.worst),
typeFps: round(type.fps),
typeP95: round(type.p95),
typeWorst: round(type.worst),
// Components whose OWN state changed with no prop change: the roots.
idleOwners: idle.owners,
idleTop: idle.top,
dragCommits: drag.commits,
dragTop: drag.top,
typeCommits: type.commits,
typeTop: type.top
}
}
}
}
@@ -0,0 +1,39 @@
// Scenario registry. Add a scenario module here and it's automatically
// available to the runner, the default suite (tier 'ci'), and the baseline gate.
import coldStart from './cold-start.mjs'
import firstToken from './first-token.mjs'
import idleCost from './idle-cost.mjs'
import keystroke from './keystroke.mjs'
import multitab from './multitab.mjs'
import profileSwitch from './profile-switch.mjs'
import renderChurn from './render-churn.mjs'
import rightPane from './right-pane.mjs'
import sessionLoad from './session-load.mjs'
import sessionSwitch from './session-switch.mjs'
import stream from './stream.mjs'
import streamHistory from './stream-history.mjs'
import submit from './submit.mjs'
import transcript from './transcript.mjs'
export const SCENARIOS = {
[stream.name]: stream,
[streamHistory.name]: streamHistory,
[keystroke.name]: keystroke,
[transcript.name]: transcript,
[multitab.name]: multitab,
[renderChurn.name]: renderChurn,
[rightPane.name]: rightPane,
[idleCost.name]: idleCost,
[coldStart.name]: coldStart,
[firstToken.name]: firstToken,
[submit.name]: submit,
[sessionLoad.name]: sessionLoad,
[sessionSwitch.name]: sessionSwitch,
[profileSwitch.name]: profileSwitch
}
/** Scenarios safe to run with no LLM credits / no live backend — the default suite. */
export const CI_SCENARIOS = Object.values(SCENARIOS)
.filter(s => s.tier === 'ci')
.map(s => s.name)
@@ -0,0 +1,99 @@
// Composer input latency — keystroke → next paint. Subsumes measure-latency,
// profile-typing, and leak-typing. This is the most-felt latency in a chat app
// (users type constantly) and nothing measured it against a baseline before.
//
// Each synthetic char records the time from dispatch to the first rAF after the
// composer mutates (a paint proxy). Metrics are p50/p95/p99 and the count of
// keystrokes that missed a 16ms frame.
import { SELECTORS, sleep } from '../lib/cdp.mjs'
import { percentile } from '../lib/stats.mjs'
const INSTALL = `
(() => {
const el = document.querySelector(${JSON.stringify(SELECTORS.composer)})
if (!el) return false
el.focus()
const range = document.createRange()
range.selectNodeContents(el)
range.collapse(false)
const sel = window.getSelection()
sel.removeAllRanges()
sel.addRange(range)
window.__KEY__ = { samples: [], pending: null }
const obs = new MutationObserver(() => {
const start = window.__KEY__.pending
if (start === null) return
window.__KEY__.pending = null
requestAnimationFrame(() => window.__KEY__.samples.push(performance.now() - start))
})
obs.observe(el, { childList: true, subtree: true, characterData: true })
window.__KEY__.obs = obs
return true
})()
`
const CLEAR = `
(() => {
const el = document.querySelector(${JSON.stringify(SELECTORS.composer)})
if (el) { el.innerHTML = ''; el.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'deleteContentBackward' })) }
window.__KEY__ && window.__KEY__.obs && window.__KEY__.obs.disconnect()
})()
`
const SENTENCE =
'the quick brown fox jumps over the lazy dog while typing into this composer, which should feel instant. '
export default {
name: 'keystroke',
tier: 'ci',
description: 'Composer keystroke → paint latency while idle.',
async run(cdp, opts = {}) {
const chars = Number(opts.chars ?? 120)
const cps = Number(opts.cps ?? 15)
await cdp.send('Runtime.enable')
const installed = await cdp.eval(INSTALL)
if (!installed) {
throw new Error(`composer not found (${SELECTORS.composer}); is a chat view open?`)
}
let text = ''
while (text.length < chars) {
text += SENTENCE
}
text = text.slice(0, chars)
const intervalMs = Math.max(1, Math.round(1000 / cps))
const start = Date.now()
for (let i = 0; i < text.length; i++) {
await cdp.eval('window.__KEY__.pending = performance.now()')
await cdp.send('Input.dispatchKeyEvent', { type: 'char', text: text[i], unmodifiedText: text[i] })
const wait = start + (i + 1) * intervalMs - Date.now()
if (wait > 0) {
await sleep(wait)
}
}
await sleep(300)
const samples = await cdp.eval('window.__KEY__.samples')
await cdp.eval(CLEAR)
const round = n => Math.round(n * 10) / 10
return {
metrics: {
keystroke_p50_ms: round(percentile(samples, 0.5)),
keystroke_p95_ms: round(percentile(samples, 0.95)),
keystroke_p99_ms: round(percentile(samples, 0.99)),
keystroke_slow_16: samples.filter(s => s > 16).length
},
detail: { n: samples.length, typed: text.length }
}
}
}
@@ -0,0 +1,426 @@
// Multi-tab working sessions: N session tiles stacked as tabs in the main
// zone, EVERY tab mounted (keep-alive), all streaming concurrently — the
// "5 tabs doing PR review" workload. Measures frame pacing + longtasks while
// the whole stack streams, which is where multitab renderers crawl.
//
// --zones M splits the tiles across M VISIBLE split zones (a 2×2 grid for 4)
// instead of one tab stack — the "4 tiles with 4 sessions each" workload,
// where M transcripts stream on screen at once and the rest are mounted
// keep-alive tabs behind them. --streaming S caps how many sessions are
// actually mid-turn (zone leaders first, so S=zones means "every visible
// transcript streams, every hidden tab idles"); the rest sit settled.
// --sessions N seeds a populated recents list (a lived-in sessions DB).
// --turns N sets transcript depth per tile (long sessions), and --tools makes
// every transcript an AGENT session: seeded turns carry settled tool rounds,
// and the live stream opens/completes tool calls between text chunks.
//
// Drives the real pipeline synthetically (no backend, no credits): each tick
// routes one delta per streaming session through `hook.update` — the same
// wiring-cache write (journal + publish + view sync) the gateway's delta
// flush performs — via the __HERMES_SESSION_TILES__ hook.
//
// node scripts/perf/run.mjs multitab --spawn [--tiles 5] [--tokens 240]
// node scripts/perf/run.mjs multitab --spawn --tiles 16 --zones 4 --sessions 300
import { sleep } from '../lib/cdp.mjs'
import { frameHistogram, percentile } from '../lib/stats.mjs'
// Same recorder pattern as stream.mjs (generation-guarded rAF + longtasks).
const RECORDERS = `
(() => {
window.__FT_GEN__ = (window.__FT_GEN__ || 0) + 1
const ftGen = window.__FT_GEN__
window.__FT__ = { times: [], stop: false }
let last = performance.now()
const tick = () => {
if (window.__FT_GEN__ !== ftGen || window.__FT__.stop) return
const now = performance.now()
window.__FT__.times.push(now - last)
last = now
requestAnimationFrame(tick)
}
requestAnimationFrame(tick)
window.__LT__ = { entries: [], stop: false }
try {
const po = new PerformanceObserver((list) => {
if (window.__LT__.stop) return
for (const e of list.getEntries()) window.__LT__.entries.push({ duration: e.duration, startTime: e.startTime })
})
po.observe({ entryTypes: ['longtask'] })
window.__LT__.po = po
} catch {}
return 'armed'
})()
`
const COLLECT = `
(() => {
window.__FT__.stop = true
window.__LT__.stop = true
try { window.__LT__.po && window.__LT__.po.disconnect() } catch {}
return JSON.stringify({ frames: window.__FT__.times, longtasks: window.__LT__.entries })
})()
`
/** Page-side setup: open `tiles` session tiles — one tab stack in the main
* zone (zones=1), or spread across `zones` visible splits (a 2×2 grid for 4)
* — bind fake runtime ids, and seed each with a realistic transcript.
*
* States are written through `hook.update` — the REAL gateway write path
* (wiring cache + in-flight journal + publish + view sync). Driving
* `hook.publish` alone under-models a stream: it skips the journal and the
* cache, which is exactly where multi-session cost used to hide. */
const setup = (tiles, seedTurns, streamSeed, zones, seedSessions, streaming, dead, tools) => `
(() => {
const hook = window.__HERMES_SESSION_TILES__
if (!hook) return 'no-hook'
if (!hook.update) return 'no-update-hook'
// A settled tool call the way the gateway stores one: streamed args (kept
// as argsText too) and a result blob. Real agent transcripts are MOSTLY
// these — a long session is hundreds of terminal/read_file/patch rounds.
const toolPart = (sid, i, k) => {
const args = { command: 'rg -n "handler" src/module-' + i + ' | head -40', background: false }
return {
type: 'tool-call', toolCallId: sid + '-t' + i + '-' + k, toolName: k % 2 ? 'read_file' : 'terminal',
args, argsText: JSON.stringify(args),
result: JSON.stringify({ success: true, output: Array.from({ length: 18 },
(_, l) => 'src/module-' + i + '.ts:' + (l * 7 + 3) + ': const handler = wrap(ctx, retry)').join('\\n') })
}
}
const turn = (sid, i) => {
const answer = { id: sid + '-a' + i, role: 'assistant', timestamp: Date.now(), pending: false,
parts: [{ type: 'text', text: [
'## Finding ' + i, '',
'The handler swallows the rejection. Key points for hunk \\\`' + i + '\\\`:', '',
'- The catch block drops the original error.',
'- Retries are unbounded — see [the loop](https://example.com/loop).', '',
'\\\`\\\`\\\`ts',
'async function retry' + i + '(fn: () => Promise<void>) {',
' for (;;) { try { return await fn() } catch {} }',
'}',
'\\\`\\\`\\\`', '',
'| path | covered |', '|---|---|', '| happy | yes |', '| error | no |', ''
].join('\\n') }] }
const rows = [
{ id: sid + '-u' + i, role: 'user', timestamp: Date.now(),
parts: [{ type: 'text', text: 'Review question ' + i + ': does the diff in module ' + i + ' handle the error path?' }] }
]
// Agent work turn (--tools): two tool rounds before the answer, the
// shape run_conversation actually produces.
if (${tools}) {
rows.push({ id: sid + '-w' + i, role: 'assistant', timestamp: Date.now(), pending: false,
parts: [{ type: 'text', text: 'Checking module ' + i + '.' }, toolPart(sid, i, 0), toolPart(sid, i, 1)] })
}
rows.push(answer)
return rows
}
const state = (sid, rid, isStreaming) => {
const messages = []
for (let i = 0; i < ${seedTurns}; i++) messages.push(...turn(sid, i))
// Streaming tail the driver grows (--code seeds an open fence); a
// non-streaming session sits settled — open, mounted, mid-nothing.
if (isStreaming) {
messages.push({ id: sid + '-stream', role: 'assistant', timestamp: Date.now(), pending: true,
parts: [{ type: 'text', text: ${JSON.stringify(streamSeed)} }] })
}
return {
storedSessionId: sid, messages, branch: '', cwd: '', model: '', provider: '',
reasoningEffort: '', serviceTier: '', fast: false, yolo: false, personality: '',
busy: isStreaming, awaitingResponse: false,
streamId: isStreaming ? sid + '-stream' : null, sawAssistantPayload: true,
pendingBranchGroup: null, interrupted: false, interimBoundaryPending: false,
needsInput: false, turnStartedAt: isStreaming ? Date.now() : null, usage: null
}
}
// A populated recents list (--sessions): every store publish re-runs the
// busy/attention/draft projections against it, so an empty list hides
// that scaling. Restored by CLEANUP.
if (${seedSessions} > 0) {
window.__MT_SAVED_SESSIONS__ = hook.sessions()
const rows = []
for (let i = 0; i < ${seedSessions}; i++) {
rows.push({
id: 'perf-row-' + i, title: 'Seeded session ' + i, ended_at: null,
input_tokens: 1200, output_tokens: 800, is_active: false,
last_active: Date.now() - i * 60000, message_count: 12,
model: 'hermes-4', preview: 'seeded row', cwd: '/tmp/proj-' + (i % 7)
})
}
hook.seedSessions(rows)
}
// Leaked residue (--dead): sessions that ran with no surface referencing
// them and then settled — what a day of opening and closing tiles
// accumulates. Modeled on the real path (insert while busy, then the
// settle publish) so publish-time eviction, where present, engages.
// CLEANUP drops whatever survives, for builds without eviction.
window.__MT_DEAD__ = []
for (let d = 0; d < ${dead}; d++) {
const sid = 'perf-dead-' + d
const rid = 'perf-dead-rt-' + d
window.__MT_DEAD__.push(rid)
const settled = state(sid, rid, false)
hook.publish(rid, { ...settled, busy: true })
hook.publish(rid, settled)
}
// Zone leaders open as visible splits (right of the workspace, then
// subdividing that column into a grid); followers stack as tabs into
// their zone. zones=1 keeps the classic one-stack workload.
const perZone = Math.ceil(${tiles} / ${zones})
const leaders = []
// Streaming slots go to zone LEADERS first (rank orders round-robin across
// zones), so --streaming ${'$'}{zones} means "every VISIBLE transcript streams,
// every hidden tab idles" — the split the all-vs-visible snapshots diff.
window.__MT__ = { ids: [], leaders, streaming: [], timer: null }
for (let n = 1; n <= ${tiles}; n++) {
const sid = 'perf-tile-' + n
const rid = 'perf-rt-' + n
window.__MT__.ids.push({ sid, rid })
const zone = ${zones} > 1 ? Math.floor((n - 1) / perZone) : 0
const posInZone = ${zones} > 1 ? (n - 1) % perZone : n - 1
const rank = posInZone * ${zones} + zone
const isStreaming = rank < ${streaming}
if (isStreaming) window.__MT__.streaming.push(rid)
const leader = leaders[zone]
if (leader) {
hook.open(sid, 'center', 'session-tile:' + leader)
} else if (${zones} === 1) {
hook.open(sid, 'center')
} else {
leaders[zone] = sid
if (zone === 0) hook.open(sid, 'right')
else if (zone === 1) hook.open(sid, 'bottom', 'session-tile:' + leaders[0])
else hook.open(sid, 'right', 'session-tile:' + leaders[zone - 2])
}
hook.patch(sid, { runtimeId: rid })
hook.update(rid, () => state(sid, rid, isStreaming))
}
return 'ok'
})()
`
// Activate every tab once so keep-alive mounts the full stack (lazy mount:
// a never-activated tab stays unmounted, which would understate the cost).
const reveal = sid => `window.__HERMES_LAYOUT_TREE__.reveal(${JSON.stringify(`session-tile:${sid}`)})`
/** Page-side driver: grow every tile's streaming tail by `chunk` each
* `intervalMs`, through the same write path the gateway flush uses.
*
* With `tools`, the stream is a working AGENT turn, not a monologue: every
* 12th tick opens a live tool call on the streaming message (args, no
* result — the running spinner), every 12th+6 completes it with a result
* blob, and text keeps flowing between rounds. That exercises the tool-part
* update path (find + replace inside the parts array) and the ToolCall
* renderer's pending→complete transitions, which text-only streaming never
* touches. */
const drive = (chunk, intervalMs, totalTokens, tools) => `
(() => {
const hook = window.__HERMES_SESSION_TILES__
let pushed = 0
const tick = () => {
for (const rid of window.__MT__.streaming) {
hook.update(rid, prev => {
if (!prev.streamId) return prev
const messages = prev.messages.map(m => {
if (m.id !== prev.streamId) return m
const parts = m.parts.slice()
if (${tools} && pushed % 12 === 0) {
const args = { command: 'npm test -- --run suite-' + pushed, background: false }
parts.push({ type: 'tool-call', toolCallId: rid + '-live-' + pushed, toolName: 'terminal',
args, argsText: JSON.stringify(args) })
} else if (${tools} && pushed % 12 === 6) {
for (let p = parts.length - 1; p >= 0; p--) {
const part = parts[p]
if (part.type === 'tool-call' && part.result === undefined) {
parts[p] = { ...part, result: JSON.stringify({ success: true,
output: 'suite-' + pushed + ': 214 passed, 0 failed\\n'.repeat(12) }) }
break
}
}
parts.push({ type: 'text', text: '' })
} else {
const last = parts[parts.length - 1]
if (last && last.type === 'text') {
parts[parts.length - 1] = { type: 'text', text: last.text + ${JSON.stringify(chunk)} }
} else {
parts.push({ type: 'text', text: ${JSON.stringify(chunk)} })
}
}
return { ...m, parts }
})
return { ...prev, messages }
})
}
pushed += 1
if (pushed < ${totalTokens}) window.__MT__.timer = setTimeout(tick, ${intervalMs})
else window.__MT__.done = true
}
window.__MT__.timer = setTimeout(tick, ${intervalMs})
return 'driving'
})()
`
const CLEANUP = `
(() => {
const hook = window.__HERMES_SESSION_TILES__
if (window.__MT_DEAD__) {
for (const rid of window.__MT_DEAD__) hook.drop?.(rid)
window.__MT_DEAD__ = null
}
if (window.__MT__) {
clearTimeout(window.__MT__.timer)
for (const { sid, rid } of window.__MT__.ids) {
// Settle through the real path so the in-flight journal entry clears.
hook.update(rid, prev => ({ ...prev, busy: false, streamId: null }))
hook.close(sid)
}
window.__MT__ = null
}
if (window.__MT_SAVED_SESSIONS__) {
hook.seedSessions(window.__MT_SAVED_SESSIONS__)
window.__MT_SAVED_SESSIONS__ = null
}
return 'cleaned'
})()
`
export default {
name: 'multitab',
tier: 'ci',
description: 'N mounted session-tile tabs all streaming: frame pacing + longtasks.',
async run(cdp, opts = {}) {
const tiles = Number(opts.tiles ?? 5)
const zones = Number(opts.zones ?? 1)
const seedTurns = Number(opts.turns ?? 20)
const seedSessions = Number(opts.sessions ?? 0)
const streaming = Math.min(Number(opts.streaming ?? tiles), tiles)
const dead = Number(opts.dead ?? 0)
// --tools: seeded turns carry settled tool rounds and the live stream
// opens/completes tool calls between text — an agent working, not talking.
const tools = Boolean(opts.tools)
const tokens = Number(opts.tokens ?? 240)
// Matches STREAM_DELTA_FLUSH_MS — one publish per session per real flush.
const intervalMs = Number(opts.intervalMs ?? 33)
// --code: every tile grows ONE giant fenced code block with no settle
// boundaries — what a coding agent streams. The block re-parses and
// re-renders fully every flush (block memoization can't settle it), the
// documented worst case and the "5 tabs all coding" crawl.
const chunk = opts.code
? ' const value = await resolve(ctx, { retry: true }) // step\n'
: (opts.chunk ?? 'A streamed review sentence with **bold**, `code`, and ordinary prose.\n\n')
const streamSeed = opts.code ? '```ts\n' : ''
await cdp.send('Runtime.enable')
const ok = await cdp.eval(setup(tiles, seedTurns, streamSeed, zones, seedSessions, streaming, dead, tools))
if (ok !== 'ok') {
throw new Error(`multitab setup failed (${ok}) — dev hooks missing? (needs a dev/probe renderer)`)
}
// Mount every tab (keep-alive mounts on first activation), then settle.
// Each reveal is timed to the next paint — with deep transcripts the
// first mount is the "why does switching tabs hang" number.
const revealMs = []
for (let n = 1; n <= tiles; n++) {
const ms = Number(
await cdp.eval(`
new Promise(resolve => {
const t0 = performance.now()
${reveal(`perf-tile-${n}`)}
requestAnimationFrame(() => requestAnimationFrame(() => resolve(performance.now() - t0)))
})
`)
)
revealMs.push(ms)
await sleep(350)
}
// Front each zone's leader so the visible set is one transcript per zone
// (the reveal loop above leaves each zone on its LAST tab).
if (zones > 1) {
const leaders = JSON.parse(await cdp.eval('JSON.stringify(window.__MT__.leaders)'))
for (const sid of leaders) {
await cdp.eval(reveal(sid))
await sleep(150)
}
}
await sleep(1000)
await cdp.eval(RECORDERS)
await cdp.eval(drive(chunk, intervalMs, tokens, tools))
await sleep(tokens * intervalMs + 1500)
const data = JSON.parse(await cdp.eval(COLLECT))
await cdp.eval(CLEANUP)
// Drop the first 500ms (recorder install + settle).
const frames = []
let acc = 0
for (const f of data.frames) {
acc += f
if (acc >= 500) {
frames.push(f)
}
}
const ltDurations = data.longtasks.map(e => e.duration)
const windowS = frames.reduce((a, b) => a + b, 0) / 1000
// The felt numbers: sustained fps over the window, and the fps of the
// worst 1-second slice (a 333ms frame IS "3fps" even if the average looks
// fine). Worst slice = max summed frame time in any sliding 1s window.
const avgFps = windowS ? frames.length / windowS : 0
let worstFps = avgFps
for (let i = 0, j = 0, sum = 0; j < frames.length; j++) {
sum += frames[j]
while (sum > 1000) {
sum -= frames[i++]
}
// Only a window that actually spans ~1s counts; short prefixes don't.
if (sum >= 900) {
worstFps = Math.min(worstFps, ((j - i + 1) / sum) * 1000)
}
}
return {
metrics: {
longtasks_n: data.longtasks.length,
longtask_max_ms: Math.round((ltDurations.length ? Math.max(...ltDurations) : 0) * 10) / 10,
frame_p95_ms: Math.round(percentile(frames, 0.95) * 10) / 10,
frame_p99_ms: Math.round(percentile(frames, 0.99) * 10) / 10,
slow_frames_33: frames.filter(f => f > 33).length,
reveal_max_ms: Math.round(Math.max(...revealMs) * 10) / 10
},
detail: {
tiles,
zones,
streaming,
dead,
sessions: seedSessions,
tools,
turns: seedTurns,
code: Boolean(opts.code),
windowS: Math.round(windowS * 10) / 10,
avgFps: Math.round(avgFps * 10) / 10,
worstSecondFps: Math.round(worstFps * 10) / 10,
revealMs: revealMs.map(v => Math.round(v)),
frameHistogram: frameHistogram(frames)
}
}
}
}
@@ -0,0 +1,60 @@
// Profile-switch latency. Subsumes measure-profile-switch. Backend tier: needs
// a configured profile in the rail and a live backend. Report-only.
//
// node scripts/perf/run.mjs profile-switch --profile <name>
import { SELECTORS, sleep } from '../lib/cdp.mjs'
export default {
name: 'profile-switch',
tier: 'backend',
description: 'Click a profile in the rail and wait for its sidebar to settle.',
requiredOpts: ['profile'],
async run(cdp, opts = {}) {
const profile = opts.profile
const settleTimeoutMs = Number(opts.settleTimeoutMs ?? 60000)
if (!profile) {
throw new Error('profile-switch needs --profile <name>')
}
await cdp.send('Runtime.enable')
const t0 = await cdp.eval(`(() => {
const rail = document.querySelector(${JSON.stringify(SELECTORS.profileRail)})
if (!rail) return null
const target = [...rail.querySelectorAll('button, [role="tab"]')].find(b =>
((b.getAttribute('aria-label') || '') + ' ' + (b.title || '') + ' ' + (b.textContent || ''))
.toLowerCase().includes(${JSON.stringify(String(profile).toLowerCase())}))
if (!target) return null
target.click()
return performance.now()
})()`)
if (t0 === null) {
throw new Error(`profile "${profile}" not found in the rail`)
}
const deadline = Date.now() + settleTimeoutMs
let settledMs = null
while (Date.now() < deadline) {
await sleep(100)
const s = await cdp.eval(`(() => {
const label = [...document.querySelectorAll('div[aria-hidden]')].find(el => /waking up/i.test(el.textContent || ''))
const overlayVisible = label ? Number(getComputedStyle(label).opacity) > 0.05 : false
return { t: performance.now(), overlayVisible, rows: document.querySelectorAll(${JSON.stringify(SELECTORS.rowButton)}).length }
})()`)
if (!s.overlayVisible && s.rows > 0) {
settledMs = s.t - t0
break
}
}
return {
metrics: { profile_switch_settled_ms: settledMs === null ? -1 : Math.round(settledMs) },
detail: { profile, timedOut: settledMs === null }
}
}
}
@@ -0,0 +1,259 @@
// Render churn during multi-tab streaming: WHAT re-rendered and WHY, and which
// store published the update. Frame pacing (see `multitab`) tells you the cost;
// this tells you the cause.
//
// Drives the same synthetic pipeline as `multitab` — publishSessionState per
// session per flush via `__HERMES_SESSION_TILES__`, no backend, no credits —
// then reads the dev-only counters installed by `src/debug/`:
//
// window.__RENDER_COUNTS__ — per-component renders, attributed to
// props / hook state / parent-only ("wasted")
// window.__ATOM_CHURN__ — per-store notifications, listener fan-out, and
// notifications whose value was deep-equal to the
// previous one ("wasted")
//
// The headline metric is `sidebar_renders`: how many times the sidebar tree
// re-rendered while agents were typing in other tabs. It should be 0.
//
// node scripts/perf/run.mjs render-churn --spawn [--tiles 5] [--tokens 240]
import { sleep } from '../lib/cdp.mjs'
/** Components that make up the sidebar tree. A render of any of these while
* a background tab streams is work the user cannot see. */
const SIDEBAR_COMPONENTS = [
'ChatSidebar',
'SidebarSurface',
'SessionRow',
'SessionsSection',
'CronJobsSection',
'ProfileSwitcher',
'VirtualSessionList',
'WorkspaceGroup',
'OverviewRow',
'SessionStatusDot'
]
/** Page-side setup: open `tiles` session tiles, seed each with a transcript.
* Mirrors `multitab.mjs` so the two scenarios measure the same workload. */
const setup = (tiles, seedTurns) => `
(() => {
const hook = window.__HERMES_SESSION_TILES__
if (!hook) return 'no-hook'
if (!window.__RENDER_COUNTS__) return 'no-render-counter'
if (!window.__ATOM_CHURN__) return 'no-atom-churn'
const turn = (sid, i) => ([
{ id: sid + '-u' + i, role: 'user', timestamp: Date.now(),
parts: [{ type: 'text', text: 'Review question ' + i + ': does the diff handle the error path?' }] },
{ id: sid + '-a' + i, role: 'assistant', timestamp: Date.now(), pending: false,
parts: [{ type: 'text', text: '## Finding ' + i + '\\n\\nThe handler swallows the rejection.\\n\\n- The catch block drops the error.\\n- Retries are unbounded.\\n' }] }
])
const state = (sid) => {
const messages = []
for (let i = 0; i < ${seedTurns}; i++) messages.push(...turn(sid, i))
messages.push({ id: sid + '-stream', role: 'assistant', timestamp: Date.now(), pending: true,
parts: [{ type: 'text', text: '' }] })
return {
storedSessionId: sid, messages, branch: '', cwd: '', model: '', provider: '',
reasoningEffort: '', serviceTier: '', fast: false, yolo: false, personality: '',
busy: true, awaitingResponse: false, streamId: sid + '-stream', sawAssistantPayload: true,
pendingBranchGroup: null, interrupted: false, interimBoundaryPending: false,
needsInput: false, turnStartedAt: Date.now(), usage: null
}
}
window.__RC__ = { ids: [], timer: null }
for (let n = 1; n <= ${tiles}; n++) {
const sid = 'churn-tile-' + n
const rid = 'churn-rt-' + n
window.__RC__.ids.push({ sid, rid })
hook.open(sid, 'center')
hook.patch(sid, { runtimeId: rid })
hook.publish(rid, state(sid))
}
return 'ok'
})()
`
const reveal = sid => `window.__HERMES_LAYOUT_TREE__.reveal(${JSON.stringify(`session-tile:${sid}`)})`
/** Grow every tile's streaming tail by `chunk` each `intervalMs`, through the
* same publish path the gateway's delta flush uses. */
const drive = (chunk, intervalMs, totalTokens) => `
(() => {
const hook = window.__HERMES_SESSION_TILES__
let pushed = 0
const tick = () => {
const states = hook.states()
for (const { rid } of window.__RC__.ids) {
const prev = states[rid]
if (!prev) continue
const messages = prev.messages.map(m => {
if (m.id !== prev.streamId) return m
const head = m.parts.slice(0, -1)
const last = m.parts[m.parts.length - 1]
return { ...m, parts: [...head, { type: 'text', text: last.text + ${JSON.stringify(chunk)} }] }
})
hook.publish(rid, { ...prev, messages })
}
pushed += 1
if (pushed < ${totalTokens}) window.__RC__.timer = setTimeout(tick, ${intervalMs})
else window.__RC__.done = true
}
window.__RC__.timer = setTimeout(tick, ${intervalMs})
return 'driving'
})()
`
/** Wait until the renderer stops committing on its own, so the recording window
* captures STREAMING cost and not whatever boot/hydration work happened to
* still be in flight. Returns `quiet:N` once commits hold still for `quietMs`.
*
* If it returns `timeout:...` the app never went idle at all — with tiles
* marked busy and NO driver running, that means something is ticking on its
* own. The report of what rendered during the wait is attached so the culprit
* is named rather than guessed at. */
const quiesce = (quietMs, timeoutMs) => `
(async () => {
const rc = window.__RENDER_COUNTS__
rc.start()
const deadline = Date.now() + ${timeoutMs}
const startedAt = Date.now()
let last = -1
let stableSince = Date.now()
while (Date.now() < deadline) {
await new Promise(r => setTimeout(r, 100))
const n = rc.commits()
if (n !== last) { last = n; stableSince = Date.now(); continue }
if (Date.now() - stableSince >= ${quietMs}) { rc.stop(); return 'quiet:' + n }
}
const idle = {
commits: last,
seconds: (Date.now() - startedAt) / 1000,
top: rc.report(8),
// Who OWNS the update? The component whose own hook state changed with
// no changed props is the root of a churn cascade; everything under it
// is collateral. Naming it is the difference between fixing the cause
// and memoizing a symptom.
owners: rc.report(200).filter(r => r.stateChanged > 0 && r.propsChanged === 0).slice(0, 8)
}
rc.stop()
return 'timeout:' + JSON.stringify(idle)
})()
`
const START = `
(() => {
window.__RENDER_COUNTS__.start()
window.__ATOM_CHURN__.start()
return 'recording'
})()
`
const COLLECT = `
(() => {
window.__RENDER_COUNTS__.stop()
window.__ATOM_CHURN__.stop()
return JSON.stringify({
commits: window.__RENDER_COUNTS__.commits(),
renders: window.__RENDER_COUNTS__.report(200),
atoms: window.__ATOM_CHURN__.report(200)
})
})()
`
const CLEANUP = `
(() => {
if (window.__RC__) {
clearTimeout(window.__RC__.timer)
for (const { sid, rid } of window.__RC__.ids) {
const states = window.__HERMES_SESSION_TILES__.states()
window.__HERMES_SESSION_TILES__.publish(rid, { ...states[rid], busy: false, streamId: null })
window.__HERMES_SESSION_TILES__.close(sid)
}
window.__RC__ = null
}
window.__RENDER_COUNTS__.clear()
window.__ATOM_CHURN__.clear()
return 'cleaned'
})()
`
export default {
name: 'render-churn',
tier: 'ci',
description: 'N streaming tabs: per-component render attribution + store churn.',
async run(cdp, opts = {}) {
const tiles = Number(opts.tiles ?? 5)
const seedTurns = Number(opts.turns ?? 20)
const tokens = Number(opts.tokens ?? 240)
// Matches STREAM_DELTA_FLUSH_MS — one publish per session per real flush.
const intervalMs = Number(opts.intervalMs ?? 33)
const chunk = opts.chunk ?? 'A streamed review sentence with **bold** and `code`.\n\n'
await cdp.send('Runtime.enable')
const ok = await cdp.eval(setup(tiles, seedTurns))
if (ok !== 'ok') {
throw new Error(
`render-churn setup failed (${ok}) — needs a dev renderer with src/debug installed ` +
'(the counters are aliased out of production builds unless VITE_PERF_PROBE=1).'
)
}
// Mount every tab (keep-alive mounts on first activation), then settle.
for (let n = 1; n <= tiles; n++) {
await cdp.eval(reveal(`churn-tile-${n}`))
await sleep(350)
}
// Let the app go quiet before recording, so boot/hydration commits that
// happen to still be in flight don't land in the streaming window. This is
// what makes runs comparable — a fixed sleep let 2-4x of hydration churn
// leak in depending on machine load.
const settle = await cdp.eval(quiesce(600, 15000))
await cdp.eval(START)
await cdp.eval(drive(chunk, intervalMs, tokens))
await sleep(tokens * intervalMs + 1500)
const data = JSON.parse(await cdp.eval(COLLECT))
await cdp.eval(CLEANUP)
const byName = new Map(data.renders.map(r => [r.name, r]))
const sidebarRows = SIDEBAR_COMPONENTS.map(n => byName.get(n)).filter(Boolean)
const sidebarRenders = sidebarRows.reduce((a, r) => a + r.renders, 0)
const sidebarWasted = sidebarRows.reduce((a, r) => a + r.wasted, 0)
const totalRenders = data.renders.reduce((a, r) => a + r.renders, 0)
const totalWasted = data.renders.reduce((a, r) => a + r.wasted, 0)
const atomWasted = data.atoms.reduce((a, r) => a + r.wasted, 0)
return {
metrics: {
// The hypothesis, as a number: sidebar renders while background tabs
// stream. Should be 0.
sidebar_renders: sidebarRenders,
sidebar_wasted: sidebarWasted,
// Renders with no changed props and no changed hook state — pure
// parent-driven work, across the whole tree.
wasted_renders: totalWasted,
total_renders: totalRenders,
commits: data.commits,
// Store notifications that published a value equal to the last one.
wasted_notifies: atomWasted
},
detail: {
tiles,
tokens,
// 'quiet:N' = the app went idle before recording (comparable run).
// 'timeout:N' = it never did, so boot churn is mixed into the numbers.
settle,
sidebar: sidebarRows,
topRenders: data.renders.slice(0, 15),
topAtoms: data.atoms.slice(0, 15)
}
}
}
}
@@ -0,0 +1,315 @@
// File-tree + terminal workspace stress. This is the regression scene for the
// desktop symptom where opening the project tree/terminal made the whole page
// hitch while chat and PTY output continued.
//
// It mounts a real project tree, one PTY plus multiple persistent xterm tabs,
// streams chat and terminal output together, mutates Git decoration state, and
// drags the terminal split. The debug probe records the specific work we care
// about rather than inferring it from CPU alone:
// - fixed-overlay measurements
// - active/hidden xterm fits
// - ProjectTree + per-path row renders
// - frame pacing / slow frames
//
// npm run perf -- right-pane --spawn --prod --runs 3
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { sleep } from '../lib/cdp.mjs'
import { frameHistogram, percentile } from '../lib/stats.mjs'
const DEFAULT_CWD = resolve(dirname(fileURLToPath(import.meta.url)), '../../..')
const RECORDERS = `
(() => {
window.__RP_FRAME_GEN__ = (window.__RP_FRAME_GEN__ || 0) + 1
const generation = window.__RP_FRAME_GEN__
window.__RP_FRAMES__ = { times: [], stop: false }
let last = performance.now()
const tick = () => {
if (window.__RP_FRAME_GEN__ !== generation || window.__RP_FRAMES__.stop) return
const now = performance.now()
window.__RP_FRAMES__.times.push(now - last)
last = now
requestAnimationFrame(tick)
}
requestAnimationFrame(tick)
window.__RP_LONG__ = { entries: [], stop: false }
try {
const observer = new PerformanceObserver(list => {
if (window.__RP_LONG__.stop) return
for (const entry of list.getEntries()) {
window.__RP_LONG__.entries.push({ duration: entry.duration, startTime: entry.startTime })
}
})
observer.observe({ entryTypes: ['longtask'] })
window.__RP_LONG__.observer = observer
} catch {}
return 'armed'
})()
`
const COLLECT_RECORDERS = `
(() => {
window.__RP_FRAMES__.stop = true
window.__RP_LONG__.stop = true
try { window.__RP_LONG__.observer && window.__RP_LONG__.observer.disconnect() } catch {}
return JSON.stringify({ frames: window.__RP_FRAMES__.times, longtasks: window.__RP_LONG__.entries })
})()
`
const START_COUNTERS = `window.__RIGHT_PANE_PERF__.start(); 'recording'`
const SNAPSHOT_COUNTERS = `
(() => {
window.__RIGHT_PANE_PERF__.stop()
return JSON.stringify(window.__RIGHT_PANE_PERF__.snapshot())
})()
`
const DRAG_TERMINAL_SPLIT = `
(async () => {
const slot = document.querySelector('[data-terminal-slot]')
const overlay = document.querySelector('[data-persistent-terminal]')
if (!slot || !overlay) return JSON.stringify({ target: 'none', drift: -1, moved: 0 })
const slotBox = slot.getBoundingClientRect()
const candidates = [...document.querySelectorAll('[role="separator"]')]
.map(element => ({ element, box: element.getBoundingClientRect() }))
.filter(item => item.box.width > item.box.height * 3)
.sort((a, b) =>
Math.abs((a.box.top + a.box.bottom) / 2 - slotBox.top) -
Math.abs((b.box.top + b.box.bottom) / 2 - slotBox.top)
)
const target = candidates[0]
if (!target) return JSON.stringify({ target: 'none', drift: -1, moved: 0 })
const x = target.box.left + target.box.width / 2
const y0 = target.box.top + target.box.height / 2
let y = y0
const pointer = {
bubbles: true, cancelable: true, pointerId: 91, pointerType: 'mouse',
isPrimary: true, button: 0, buttons: 1
}
target.element.dispatchEvent(new PointerEvent('pointerdown', { ...pointer, clientX: x, clientY: y }))
for (let i = 0; i < 24; i += 1) {
y -= 1
window.dispatchEvent(new PointerEvent('pointermove', { ...pointer, clientX: x, clientY: y }))
await new Promise(resolve => requestAnimationFrame(resolve))
}
for (let i = 0; i < 24; i += 1) {
y += 1
window.dispatchEvent(new PointerEvent('pointermove', { ...pointer, clientX: x, clientY: y }))
await new Promise(resolve => requestAnimationFrame(resolve))
}
window.dispatchEvent(new PointerEvent('pointerup', { ...pointer, buttons: 0, clientX: x, clientY: y }))
// Track-size transitions continue briefly after pointerup. Wait through
// that animation, then give the overlay its normal two-frame calibration.
await new Promise(resolve => setTimeout(resolve, 350))
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))
const a = slot.getBoundingClientRect()
const b = overlay.getBoundingClientRect()
const drift = Math.max(
Math.abs(a.top - b.top),
Math.abs(a.left - b.left),
Math.abs(a.width - b.width),
Math.abs(a.height - b.height)
)
return JSON.stringify({ target: 'horizontal-separator', drift, moved: 24 })
})()
`
async function waitFor(cdp, expression, label, timeoutMs = 20000) {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
if (await cdp.eval(expression)) {
return
}
await sleep(100)
}
throw new Error(`right-pane timed out waiting for ${label}`)
}
const trimWarmup = (frames, warmupMs = 300) => {
const kept = []
let elapsed = 0
for (const frame of frames) {
elapsed += frame
if (elapsed >= warmupMs) {
kept.push(frame)
}
}
return kept
}
export default {
name: 'right-pane',
tier: 'report',
description: 'Project tree + persistent terminal tabs under chat/terminal output and split dragging.',
async run(cdp, opts = {}) {
const cwd = resolve(String(opts.cwd ?? DEFAULT_CWD))
const terminalCount = Math.max(2, Number(opts.terminals ?? 3))
const tokens = Number(opts.tokens ?? 90)
const outputChunks = Number(opts.outputChunks ?? 160)
await cdp.send('Runtime.enable')
const ready = await cdp.eval(
`!!(window.__PERF_DRIVE__?.rightPaneSetup && window.__RIGHT_PANE_PERF__ && window.__HERMES_LAYOUT_TREE__)`
)
if (!ready) {
throw new Error('right-pane needs a dev renderer or a production build with VITE_PERF_PROBE=1.')
}
let setup
try {
setup = await cdp.eval(
`window.__PERF_DRIVE__.rightPaneSetup(${JSON.stringify({ cwd, terminals: terminalCount })})`
)
await cdp.eval(`window.__HERMES_LAYOUT_TREE__.reveal('files'); window.__HERMES_LAYOUT_TREE__.reveal('terminal')`)
await waitFor(cdp, `!!document.querySelector('[data-project-tree]')`, 'project tree')
await waitFor(
cdp,
`!!document.querySelector('[data-terminal-slot]') && !!document.querySelector('[data-persistent-terminal]')`,
'persistent terminal'
)
await waitFor(
cdp,
`document.querySelectorAll('[data-terminal] .xterm').length >= ${terminalCount}`,
`${terminalCount} mounted xterms`,
30000
)
await sleep(1200)
// Activate every keep-alive tab once, then return to the output tab.
// Each activation should restore exactly one fit; inactive tabs must stay
// at zero even while another tab resizes or writes output.
await cdp.eval(START_COUNTERS)
for (const id of setup.terminalIds) {
await cdp.eval(`window.__PERF_DRIVE__.rightPaneSelect(${JSON.stringify(id)})`)
await sleep(180)
}
const activation = JSON.parse(await cdp.eval(SNAPSHOT_COUNTERS))
await cdp.eval(RECORDERS)
// Chat DOM churn is deliberately measured in its own counter window:
// terminal positioning should receive no wakeups from transcript changes.
await cdp.eval(START_COUNTERS)
await cdp.eval(
`window.__PERF_DRIVE__.stream({
chunk: 'Right pane streaming sentence with **bold** and \`code\`.\\n\\n',
intervalMs: 16,
totalTokens: ${tokens},
flushMinMs: 33
})`
)
await cdp.eval(`
(() => {
let n = 0
window.__RP_OUTPUT_TIMER__ = setInterval(() => {
window.__PERF_DRIVE__.rightPaneWrite(
${JSON.stringify(setup.procId)},
'terminal output line ' + n + ' ........................................\\r\\n'
)
n += 1
if (n >= ${outputChunks}) clearInterval(window.__RP_OUTPUT_TIMER__)
}, 16)
return 'writing'
})()
`)
await sleep(Math.max(tokens, outputChunks) * 16 + 900)
const stream = JSON.parse(await cdp.eval(SNAPSHOT_COUNTERS))
// An unrelated Git status publication should render neither the tree root
// nor any visible row. A status for one visible path should touch only it.
await cdp.eval(START_COUNTERS)
await cdp.eval(`window.__PERF_DRIVE__.rightPaneGit('__right_pane_unrelated__.txt', 'modified')`)
await sleep(250)
const unrelatedGit = JSON.parse(await cdp.eval(SNAPSHOT_COUNTERS))
const visiblePath = await cdp.eval(
`document.querySelector('[data-project-tree] [title]')?.getAttribute('title') || ''`
)
let affectedGit = { counts: { 'project-tree-render': 0, 'project-tree-row-render': 0 }, rows: {} }
if (visiblePath) {
const relative = String(visiblePath).startsWith(`${cwd}/`)
? String(visiblePath).slice(cwd.length + 1)
: String(visiblePath)
await cdp.eval(START_COUNTERS)
await cdp.eval(`window.__PERF_DRIVE__.rightPaneGit(${JSON.stringify(relative)}, 'modified')`)
await sleep(250)
affectedGit = JSON.parse(await cdp.eval(SNAPSHOT_COUNTERS))
}
await cdp.eval(START_COUNTERS)
const drag = JSON.parse(await cdp.eval(DRAG_TERMINAL_SPLIT))
const dragCounters = JSON.parse(await cdp.eval(SNAPSHOT_COUNTERS))
const recorded = JSON.parse(await cdp.eval(COLLECT_RECORDERS))
const frames = trimWarmup(recorded.frames)
const longtasks = recorded.longtasks.map(entry => entry.duration)
const streamCounts = stream.counts
const activationCounts = activation.counts
const unrelatedCounts = unrelatedGit.counts
const affectedRows = Object.values(affectedGit.rows).reduce((sum, count) => sum + count, 0)
const affectedPaths = Object.keys(affectedGit.rows).length
if (drag.target === 'none') {
throw new Error('right-pane found no horizontal terminal split separator.')
}
return {
metrics: {
chat_terminal_measures: streamCounts['terminal-measure'],
hidden_terminal_fits: activationCounts['terminal-fit-hidden'] + streamCounts['terminal-fit-hidden'],
activation_fit_mismatch: Math.abs(activationCounts['terminal-fit-active'] - setup.terminalIds.length),
unrelated_tree_renders: unrelatedCounts['project-tree-render'],
unrelated_row_renders: unrelatedCounts['project-tree-row-render'],
affected_tree_renders: affectedGit.counts['project-tree-render'],
affected_row_path_excess: Math.max(0, affectedPaths - 1),
terminal_drift_px: Math.round(drag.drift * 10) / 10,
frame_p95_ms: Math.round(percentile(frames, 0.95) * 10) / 10,
frame_p99_ms: Math.round(percentile(frames, 0.99) * 10) / 10,
slow_frames_33: frames.filter(frame => frame > 33).length,
longtask_max_ms: Math.round((longtasks.length ? Math.max(...longtasks) : 0) * 10) / 10
},
detail: {
cwd,
terminals: setup.terminalIds.length,
activation,
stream,
unrelatedGit,
affectedGit,
affectedRows,
drag,
dragCounters,
frameHistogram: frameHistogram(frames),
frames: frames.length
}
}
} finally {
await cdp.eval(`
(() => {
clearInterval(window.__RP_OUTPUT_TIMER__)
window.__RIGHT_PANE_PERF__?.stop()
window.__PERF_DRIVE__?.reset()
return 'cleaned'
})()
`)
}
}
}
@@ -0,0 +1,136 @@
// Visual stability of a session LOAD. Sibling to `submit` (which measures the
// jump on Enter); this one measures the jump on opening a session — the
// prepend/settle path, not the append path.
//
// Clicks sidebar rows and tracks the bottom-most turn's on-screen top every
// frame. A clean load never moves it after first paint; a janky one strands it
// thousands of px away while the render-budget backfill and stick-to-bottom
// argue. Backend tier: needs real stored sessions in the sidebar.
//
// node scripts/perf/run.mjs session-load --rows 2,5,8 --rounds 2
import { SELECTORS, sleep } from '../lib/cdp.mjs'
import { summarize } from '../lib/stats.mjs'
// Below this a shift is sub-perceptual (sub-pixel rounding, a settling caret).
const SHIFT_PX = 4
const ARM = `
(() => {
const samples = []
const t0 = performance.now()
let running = true
const tick = () => {
if (!running) return
const v = document.querySelector(${JSON.stringify(SELECTORS.threadViewport)})
if (v) {
const turns = v.querySelectorAll(
${JSON.stringify(SELECTORS.turnPair)} + ',' + ${JSON.stringify(SELECTORS.assistantMessage)}
)
const last = turns[turns.length - 1]
const rect = last && last.getBoundingClientRect()
samples.push({
st: Math.round(v.scrollTop),
sh: v.scrollHeight,
ch: v.clientHeight,
bottomTop: rect ? Math.round(rect.top - v.getBoundingClientRect().top) : null,
turns: turns.length,
t: Math.round(performance.now() - t0)
})
}
requestAnimationFrame(tick)
}
requestAnimationFrame(tick)
window.__SL = { samples, stop() { running = false } }
})()
`
const CLICK = index => `
(() => {
const rows = [...document.querySelectorAll(${JSON.stringify(SELECTORS.rowButton)})].filter(el => el.offsetParent)
const row = rows[${index}]
if (!row) return null
row.click()
return (row.textContent ?? '').slice(0, 34)
})()
`
/** Total/max on-screen movement of the bottom turn after it first paints. */
function measureLoad(samples) {
const painted = samples.findIndex(s => s.turns > 0)
const after = painted === -1 ? [] : samples.slice(painted)
const load = { maxShiftPx: 0, offBottomFrames: 0, settledMs: 0, shiftedPx: 0, shifts: 0 }
let previous = null
for (const sample of after) {
if (sample.sh - (sample.st + sample.ch) > 2) {
load.offBottomFrames += 1
}
if (previous?.bottomTop != null && sample.bottomTop != null) {
const delta = Math.abs(sample.bottomTop - previous.bottomTop)
if (delta > SHIFT_PX) {
load.maxShiftPx = Math.max(load.maxShiftPx, delta)
load.settledMs = sample.t
load.shiftedPx += delta
load.shifts += 1
}
}
previous = sample
}
return load
}
export default {
name: 'session-load',
tier: 'backend',
description: 'Visual stability of opening a session: how far the transcript moves after first paint.',
async run(cdp, opts = {}) {
const rows = String(opts.rows ?? '2,5,8').split(',').map(Number)
const rounds = Number(opts.rounds ?? 2)
const watchMs = Number(opts.watchMs ?? 4500)
await cdp.send('Runtime.enable')
const loads = []
for (let round = 0; round < rounds; round++) {
for (const index of rows) {
await cdp.eval(ARM)
if (!(await cdp.eval(CLICK(index)))) {
continue
}
await sleep(watchMs)
const { samples } = await cdp.eval('(() => { window.__SL.stop(); return window.__SL })()')
loads.push(measureLoad(samples))
}
await sleep(500)
}
if (!loads.length) {
throw new Error('session-load found no sidebar rows to click')
}
const total = key => loads.reduce((sum, load) => sum + load[key], 0)
return {
metrics: {
load_max_shift_px: Math.max(...loads.map(load => load.maxShiftPx)),
load_off_bottom_frames: Math.round(total('offBottomFrames') / loads.length),
load_settled_p95_ms: summarize(loads.map(load => load.settledMs)).p95,
load_shifted_px: Math.round(total('shiftedPx') / loads.length)
},
detail: { loads: loads.length, shiftsPerLoad: total('shifts') / loads.length }
}
}
}
@@ -0,0 +1,80 @@
// Session-switch latency. Subsumes profile-session-switch. Backend tier: needs
// two real stored session ids and a live backend. Report-only.
//
// node scripts/perf/run.mjs session-switch --a <sidA> --b <sidB> [--rounds 2]
import { SELECTORS, sleep } from '../lib/cdp.mjs'
import { summarize } from '../lib/stats.mjs'
export default {
name: 'session-switch',
tier: 'backend',
description: 'Route to a session and wait for first-paint + settle of its transcript.',
requiredOpts: ['a', 'b'],
async run(cdp, opts = {}) {
const { a, b } = opts
const rounds = Number(opts.rounds ?? 2)
const settleTimeoutMs = Number(opts.settleTimeoutMs ?? 30000)
if (!a || !b) {
throw new Error('session-switch needs --a <sessionId> --b <sessionId>')
}
await cdp.send('Runtime.enable')
const switchTo = async sid => {
const t0 = await cdp.eval(`(() => { location.hash = '#/' + ${JSON.stringify(sid)}; return performance.now() })()`)
const deadline = Date.now() + settleTimeoutMs
let firstPaint = null
let stable = 0
let lastCount = -1
while (Date.now() < deadline) {
await sleep(50)
const s = await cdp.eval(`({
t: performance.now(),
route: location.hash,
msgs: document.querySelectorAll(${JSON.stringify(SELECTORS.assistantMessage)}).length
})`)
if (!String(s.route).includes(sid)) {
continue
}
if (s.msgs > 0 && firstPaint === null) {
firstPaint = s.t - t0
}
stable = s.msgs === lastCount && s.msgs > 0 ? stable + 1 : 0
lastCount = s.msgs
if (stable >= 3) {
return { firstPaint, settled: s.t - t0 }
}
}
return { firstPaint, settled: null }
}
const firstPaints = []
const settles = []
for (let round = 0; round < rounds; round++) {
for (const sid of [a, b]) {
const r = await switchTo(sid)
if (typeof r.firstPaint === 'number') firstPaints.push(r.firstPaint)
if (typeof r.settled === 'number') settles.push(r.settled)
await sleep(800)
}
}
return {
metrics: {
switch_first_paint_p95_ms: summarize(firstPaints).p95,
switch_settled_p95_ms: summarize(settles).p95
},
detail: { rounds, firstPaint: summarize(firstPaints), settled: summarize(settles) }
}
}
}
@@ -0,0 +1,18 @@
// Streaming into an ALREADY-LONG transcript. Same measurement as `stream`, but
// the history is mounted and allowed to settle before the recorders start, so
// what it captures is the per-delta cost that scales with transcript length —
// the regression reported in #69120.
//
// Report-only (tier: manual): the number depends on how much history the host
// can mount, so it is not gated against the committed baseline.
import stream from './stream.mjs'
export default {
name: 'stream-history',
tier: 'manual',
description: 'Streaming cost with a long settled transcript already mounted.',
run(cdp, opts = {}) {
return stream.run(cdp, { ...opts, historyTurns: Number(opts.historyTurns ?? 200) })
}
}
@@ -0,0 +1,215 @@
// Streaming render cost. Subsumes measure-synthetic-stream, profile-synth-stream,
// profile-long-stream (synthetic) and measure-real-stream / profile-real-stream
// (via --real). CPU profiling is provided by the runner's --cpuprofile flag.
//
// Metrics (lower is better): longtask count + max, frame p95/p99, slow-frame
// count, inter-mutation p95. These are what "is streaming smooth?" reduces to.
import { SELECTORS, sleep, typeIntoComposer } from '../lib/cdp.mjs'
import { frameHistogram, percentile } from '../lib/stats.mjs'
const RECORDERS = `
(() => {
// Generation guard: a prior run's rAF loop re-reads window.__FT__ each frame,
// so simply reassigning it would leave the old loop running and pushing into
// the new array (overlapping recorders inflate frame intervals on run 2+).
// Bumping the generation makes every stale loop exit on its next tick.
window.__FT_GEN__ = (window.__FT_GEN__ || 0) + 1
const ftGen = window.__FT_GEN__
window.__FT__ = { times: [], stop: false }
let last = performance.now()
const tick = () => {
if (window.__FT_GEN__ !== ftGen || window.__FT__.stop) return
const now = performance.now()
window.__FT__.times.push(now - last)
last = now
requestAnimationFrame(tick)
}
requestAnimationFrame(tick)
window.__LT__ = { entries: [], stop: false }
try {
const po = new PerformanceObserver((list) => {
if (window.__LT__.stop) return
for (const e of list.getEntries()) window.__LT__.entries.push({ duration: e.duration, startTime: e.startTime })
})
po.observe({ entryTypes: ['longtask'] })
window.__LT__.po = po
} catch {}
window.__MO__ = { mutations: [], stop: false, current: null }
window.__MO__.arm = () => {
const all = document.querySelectorAll(${JSON.stringify(SELECTORS.assistantMessage)})
const last = all[all.length - 1]
if (!last || last === window.__MO__.current) return
window.__MO__.current = last
window.__MO__.obs && window.__MO__.obs.disconnect()
const obs = new MutationObserver(() => {
if (window.__MO__.stop) return
window.__MO__.mutations.push({ t: performance.now(), len: last.textContent.length })
})
obs.observe(last, { childList: true, subtree: true, characterData: true })
window.__MO__.obs = obs
}
return 'armed'
})()
`
const COLLECT = `
(() => {
window.__FT__.stop = true
window.__LT__.stop = true
window.__MO__.stop = true
try { window.__LT__.po && window.__LT__.po.disconnect() } catch {}
try { window.__MO__.obs && window.__MO__.obs.disconnect() } catch {}
return JSON.stringify({
frames: window.__FT__.times,
longtasks: window.__LT__.entries,
mutations: window.__MO__.mutations
})
})()
`
function analyze(data, warmupMs, extra = {}) {
// Drop warm-up frames (recorder installs before the stream starts).
const frames = []
let acc = 0
for (const f of data.frames) {
acc += f
if (acc >= warmupMs) {
frames.push(f)
}
}
const interMut = []
for (let i = 1; i < data.mutations.length; i++) {
interMut.push(data.mutations[i].t - data.mutations[i - 1].t)
}
const ltDurations = data.longtasks.map(e => e.duration)
const windowS = frames.reduce((a, b) => a + b, 0) / 1000
return {
metrics: {
longtasks_n: data.longtasks.length,
longtask_max_ms: Math.round((ltDurations.length ? Math.max(...ltDurations) : 0) * 10) / 10,
frame_p95_ms: Math.round(percentile(frames, 0.95) * 10) / 10,
frame_p99_ms: Math.round(percentile(frames, 0.99) * 10) / 10,
slow_frames_33: frames.filter(f => f > 33).length,
intermut_p95_ms: Math.round(percentile(interMut, 0.95) * 10) / 10
},
detail: {
...extra,
windowS: Math.round(windowS * 10) / 10,
avgFps: windowS ? Math.round((frames.length / windowS) * 10) / 10 : 0,
frameHistogram: frameHistogram(frames),
mutations: data.mutations.length,
finalLen: data.mutations.at(-1)?.len ?? 0
}
}
}
export default {
name: 'stream',
tier: 'ci',
description: 'Assistant-message streaming: longtasks, frame pacing, mutation cadence.',
async run(cdp, opts = {}) {
const tokens = Number(opts.tokens ?? 400)
const intervalMs = Number(opts.intervalMs ?? 16)
const flushMinMs = Number(opts.flushMinMs ?? 33)
// Realistic default: a short markdown paragraph ending in a blank line, so
// blocks SETTLE as they stream — exactly how real LLM output behaves, and
// what block-memoization is designed for (only the growing tail re-renders).
// A chunk with NO paragraph break (e.g. `--chunk 'word '`) instead grows one
// ever-larger block that re-renders fully every flush — a useful worst-case
// stress, but not the typical number. No raw autolink (avoids DNS/link-embed
// noise unrelated to render cost).
const chunk = opts.chunk ?? 'A streamed sentence with **bold**, `code`, and ordinary prose like a normal reply.\n\n'
const real = Boolean(opts.real)
const historyTurns = Number(opts.historyTurns ?? 0)
const historySettleMs = Number(opts.historySettleMs ?? 1500)
await cdp.send('Runtime.enable')
// Mount the settled history BEFORE the recorders start, so the measurement
// window contains only streaming work — not the one-off mount cost.
if (historyTurns > 0) {
if (real) {
throw new Error('--historyTurns is only supported by the synthetic stream path')
}
await cdp.eval(`window.__PERF_DRIVE__.loadTranscript(${historyTurns})`)
await sleep(historySettleMs)
const mounted = Number(await cdp.eval('window.__PERF_DRIVE__.snapshotMsgs()'))
const expected = historyTurns * 2
if (mounted !== expected) {
throw new Error(`expected ${expected} preloaded history messages, got ${mounted}`)
}
}
await cdp.eval(RECORDERS)
if (real) {
// Backend path: fire a real prompt and wait for the stream to appear.
const baseCount = await cdp.eval(
`document.querySelectorAll(${JSON.stringify(SELECTORS.assistantMessage)}).length`
)
await typeIntoComposer(cdp, opts.prompt ?? 'count from 1 to 80, one number per line', { cps: 40 })
await cdp.eval(`(() => {
const el = document.querySelector(${JSON.stringify(SELECTORS.composer)})
el && el.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', bubbles: true, cancelable: true }))
})()`)
const deadline = Date.now() + Number(opts.timeoutMs ?? 60000)
let started = false
while (Date.now() < deadline) {
await sleep(50)
const n = await cdp.eval(`document.querySelectorAll(${JSON.stringify(SELECTORS.assistantMessage)}).length`)
if (n > baseCount) {
started = true
break
}
}
if (!started) {
throw new Error('real stream never started (no LLM credit / backend?)')
}
await cdp.eval('window.__MO__.arm()')
// Let it run to completion or timeout.
const runDeadline = Date.now() + Number(opts.runMs ?? 30000)
while (Date.now() < runDeadline) {
await sleep(250)
const busy = await cdp.eval(`!!document.querySelector('[data-status="running"], [data-busy="true"]')`)
if (!busy) {
break
}
}
} else {
// Synthetic path: drive $messages directly. No LLM, no credits.
await cdp.eval(
`window.__PERF_DRIVE__.stream({ chunk: ${JSON.stringify(chunk)}, intervalMs: ${intervalMs}, totalTokens: ${tokens}, flushMinMs: ${flushMinMs} })`
)
await sleep(200)
await cdp.eval('window.__MO__.arm()')
await sleep(tokens * intervalMs + 1500)
}
const data = JSON.parse(await cdp.eval(COLLECT))
if (!real) {
await cdp.eval('window.__PERF_DRIVE__.reset()')
}
return analyze(data, real ? 0 : 500, { historyTurns })
}
}
@@ -0,0 +1,87 @@
// Submit (Enter) latency + scroll stability. Subsumes measure-submit and
// measure-jump. Backend tier: fires a REAL prompt, so run it on a throwaway
// session with a live backend. Report-only (no committed baseline — real
// round-trips are too environment-dependent to gate).
import { SELECTORS, sleep, typeIntoComposer } from '../lib/cdp.mjs'
import { summarize } from '../lib/stats.mjs'
const MEASURE = `
new Promise((resolve) => {
const composer = document.querySelector(${JSON.stringify(SELECTORS.composer)})
const thread = document.querySelector(${JSON.stringify(SELECTORS.threadContent)}) ||
document.querySelector(${JSON.stringify(SELECTORS.threadViewport)})
const viewport = document.querySelector(${JSON.stringify(SELECTORS.threadViewport)})
const startCount = thread ? thread.querySelectorAll(${JSON.stringify(SELECTORS.turnPair)}).length : 0
const startScroll = viewport ? viewport.scrollTop : 0
const m = { start: performance.now(), maxJumpPx: 0 }
let done = false
const finish = (reason) => {
if (done) return
done = true
clearTimeout(timer); composerObs.disconnect(); threadObs && threadObs.disconnect()
m.reason = reason
resolve(m)
}
const composerObs = new MutationObserver(() => {
if (!m.composerClearedMs && composer && composer.innerText.length === 0) {
m.composerClearedMs = performance.now() - m.start
}
})
composer && composerObs.observe(composer, { childList: true, subtree: true, characterData: true })
let threadObs = null
if (thread) {
threadObs = new MutationObserver(() => {
if (viewport) m.maxJumpPx = Math.max(m.maxJumpPx, Math.abs(viewport.scrollTop - startScroll))
const c = thread.querySelectorAll(${JSON.stringify(SELECTORS.turnPair)}).length
if (!m.userMsgRenderedMs && c > startCount) {
m.userMsgRenderedMs = performance.now() - m.start
requestAnimationFrame(() => { m.userMsgPaintMs = performance.now() - m.start; finish('paint') })
}
})
threadObs.observe(thread, { childList: true, subtree: true })
}
const timer = setTimeout(() => finish('timeout'), 5000)
composer && composer.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', bubbles: true, cancelable: true }))
})
`
export default {
name: 'submit',
tier: 'backend',
description: 'Enter → composer cleared → user message painted, plus scroll jump.',
async run(cdp, opts = {}) {
const rounds = Number(opts.rounds ?? 3)
await cdp.send('Runtime.enable')
const clears = []
const paints = []
const jumps = []
for (let i = 0; i < rounds; i++) {
await typeIntoComposer(cdp, `perf submit round ${i} ${'x'.repeat(30)}`, { cps: 60 })
await sleep(250)
const m = await cdp.eval(MEASURE)
if (typeof m.composerClearedMs === 'number') clears.push(m.composerClearedMs)
if (typeof m.userMsgPaintMs === 'number') paints.push(m.userMsgPaintMs)
jumps.push(m.maxJumpPx ?? 0)
// Let the turn finish before the next round so they don't pile up.
await sleep(4000)
}
return {
metrics: {
submit_clear_p95_ms: summarize(clears).p95,
submit_paint_p95_ms: summarize(paints).p95,
submit_scroll_jump_max_px: Math.max(0, ...jumps)
},
detail: { rounds, clears: summarize(clears), paints: summarize(paints) }
}
}
}
@@ -0,0 +1,50 @@
// Large-transcript mount cost. New scenario (no prior script measured this):
// loads N synthetic turns of mixed markdown into $messages and records the
// mount→paint time plus any longtasks the mount blocks the main thread with.
// This is the "open a long session" path — a first-impression latency.
import { sleep } from '../lib/cdp.mjs'
const OBSERVE = `
(() => {
window.__TM__ = { longtasks: [] }
try {
const po = new PerformanceObserver((l) => {
for (const e of l.getEntries()) window.__TM__.longtasks.push(e.duration)
})
po.observe({ entryTypes: ['longtask'] })
window.__TM__.po = po
} catch {}
return 'observing'
})()
`
export default {
name: 'transcript',
tier: 'ci',
description: 'Mount + paint cost of loading a long transcript.',
async run(cdp, opts = {}) {
const turns = Number(opts.turns ?? 200)
await cdp.send('Runtime.enable')
await cdp.eval(OBSERVE)
const mountMs = await cdp.eval(`window.__PERF_DRIVE__.loadTranscript(${turns})`)
// Let post-mount longtasks (content-visibility passes, virtualizer) settle.
await sleep(1500)
const longtasks = await cdp.eval('window.__TM__.longtasks')
await cdp.eval('try { window.__TM__.po && window.__TM__.po.disconnect() } catch {}')
await cdp.eval('window.__PERF_DRIVE__.reset()')
return {
metrics: {
transcript_mount_ms: Math.round(mountMs * 10) / 10,
transcript_longtask_ms: Math.round(longtasks.reduce((a, b) => a + b, 0) * 10) / 10,
transcript_longtask_max_ms: Math.round((longtasks.length ? Math.max(...longtasks) : 0) * 10) / 10
},
detail: { turns, messages: turns * 2, longtasks: longtasks.length }
}
}
}
+40
View File
@@ -0,0 +1,40 @@
// Launch a standalone, fully isolated perf instance and leave it running so you
// can attach the harness (`npm run perf`) or DevTools to it. Ctrl-C tears it
// down and removes its temp dirs.
//
// npm run perf:serve # :9222, temp HERMES_HOME + user-data-dir
// PERF_PORT=9333 npm run perf:serve # custom CDP port
//
// This is the isolation seam: because it uses its own --user-data-dir the
// Electron single-instance lock never collides with a running `hgui`.
import { startIsolatedInstance } from './lib/launch.mjs'
const port = Number(process.env.PERF_PORT ?? 9222)
const devPort = Number(process.env.PERF_DEV_PORT ?? 5174)
console.log(`[perf:serve] starting isolated instance (CDP :${port}, dev :${devPort})…`)
const instance = await startIsolatedInstance({
port,
devPort,
hermesHome: process.env.PERF_HERMES_HOME,
userDataDir: process.env.PERF_USER_DATA
})
console.log(`[perf:serve] READY — attach with: npm run perf -- --port ${port}`)
let closing = false
const shutdown = () => {
if (closing) {
return
}
closing = true
console.log('\n[perf:serve] tearing down…')
instance.teardown()
process.exit(0)
}
process.on('SIGINT', shutdown)
process.on('SIGTERM', shutdown)
@@ -0,0 +1,146 @@
// ⌘K open latency, measured in-page (no CDP round-trip in the number).
//
// node scripts/probe-command-palette.mjs [--port 9222] [--rounds 8]
//
// Reports, per round, the time from the keydown the app actually receives to:
// frame_ms — the dialog frame + input in the DOM and painted (what "instant"
// means: the overlay owes you a frame immediately)
// rows_ms — the row list painted (may lag frame_ms; rows are deferred)
// plus any long tasks in the window, so a slow open is attributable.
import { CDP, sleep } from './perf/lib/cdp.mjs'
const args = process.argv.slice(2)
const flag = name => {
const i = args.indexOf(`--${name}`)
return i >= 0 ? args[i + 1] : undefined
}
const port = Number(flag('port') ?? 9222)
const rounds = Number(flag('rounds') ?? 8)
const cdp = await CDP.connect({ port })
await cdp.send('Runtime.enable')
const INSTALL = `
(() => {
if (window.__CMDK__) window.__CMDK__.stop()
const state = { t0: null, frame: null, rows: 0, rowsAt: null, tasks: [], armed: false }
// Time from the keydown the APP receives — excludes CDP transport, so the
// number is what a user's finger actually experiences.
const onKey = e => {
if (state.armed && (e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
state.t0 = performance.now()
state.armed = false
}
}
window.addEventListener('keydown', onKey, true)
const obs = new MutationObserver(() => {
if (state.t0 === null) return
if (state.frame === null && document.querySelector('[cmdk-input]')) {
state.frame = performance.now() - state.t0
}
const n = document.querySelectorAll('[cmdk-item]').length
if (n > state.rows) { state.rows = n; state.rowsAt = performance.now() - state.t0 }
})
obs.observe(document.body, { childList: true, subtree: true })
const po = new PerformanceObserver(list => {
for (const e of list.getEntries()) state.tasks.push({ start: e.startTime, dur: Math.round(e.duration) })
})
try { po.observe({ entryTypes: ['longtask'] }) } catch {}
window.__CMDK__ = {
arm: () => { state.t0 = null; state.frame = null; state.rows = 0; state.rowsAt = null; state.tasks = []; state.armed = true },
read: () => ({
frame_ms: state.frame === null ? -1 : Math.round(state.frame),
rows_ms: state.rowsAt === null ? -1 : Math.round(state.rowsAt),
rows: state.rows,
longtask_ms: state.t0 === null ? 0 : state.tasks.filter(t => t.start >= state.t0).reduce((s, t) => s + t.dur, 0)
}),
stop: () => { window.removeEventListener('keydown', onKey, true); obs.disconnect(); po.disconnect() }
}
return true
})()
`
// Settle: frame painted AND rows stopped growing for two frames.
const WAIT = `
new Promise(resolve => {
let stable = 0
let last = -1
const started = performance.now()
const tick = () => {
const r = window.__CMDK__.read()
if (r.frame_ms >= 0 && r.rows === last && r.rows > 0) {
if (++stable >= 2) { resolve(r); return }
} else { stable = 0 }
last = r.rows
if (performance.now() - started > 8000) { resolve(window.__CMDK__.read()); return }
requestAnimationFrame(tick)
}
requestAnimationFrame(tick)
})
`
const key = async type =>
cdp.send('Input.dispatchKeyEvent', {
type,
key: 'k',
code: 'KeyK',
windowsVirtualKeyCode: 75,
nativeVirtualKeyCode: 75,
modifiers: 4
})
const esc = async () => {
for (const type of ['keyDown', 'keyUp']) {
await cdp.send('Input.dispatchKeyEvent', { type, key: 'Escape', code: 'Escape', windowsVirtualKeyCode: 27 })
}
await sleep(400)
}
await cdp.eval(INSTALL)
await esc()
const samples = []
for (let i = 0; i < rounds; i++) {
await sleep(250)
await cdp.eval('window.__CMDK__.arm()')
await key('rawKeyDown')
await key('keyUp')
const r = await cdp.eval(WAIT)
samples.push(r)
console.log(`round ${i}:`, r)
await esc()
}
await cdp.eval('window.__CMDK__.stop()')
const stat = k => {
const v = samples.map(s => s[k]).filter(n => n >= 0).sort((a, b) => a - b)
if (!v.length) return null
return {
min: v[0],
median: v[Math.floor(v.length / 2)],
max: v[v.length - 1]
}
}
console.log('\nkeydown → dialog frame painted (ms):', stat('frame_ms'))
console.log('keydown → rows painted (ms):', stat('rows_ms'))
console.log('long-task time in window (ms):', stat('longtask_ms'))
cdp.close()
@@ -0,0 +1,80 @@
// Model picker open latency probe: click the composer model pill, time until
// the dropdown/dialog content paints, repeat. Run with --cpuprofile via the
// harness runner once promoted; standalone for iteration.
// node scripts/perf/probe-model-picker.mjs [--port 9222] [--rounds 5]
import { CDP } from './perf/lib/cdp.mjs'
const args = process.argv.slice(2)
const flag = name => {
const i = args.indexOf(`--${name}`)
return i >= 0 ? args[i + 1] : undefined
}
const port = Number(flag('port') ?? 9222)
const rounds = Number(flag('rounds') ?? 5)
const sleep = ms => new Promise(r => setTimeout(r, ms))
const cdp = await CDP.connect({ port })
await cdp.send('Runtime.enable')
// Find the pill (dropdown path) — the composer model selector button.
const PILL = `(() => {
const btns = [...document.querySelectorAll('button[aria-label]')]
const pill = btns.find(b => /model/i.test(b.getAttribute('aria-label') || '') && b.closest('[data-slot]'))
return pill ? (pill.getAttribute('aria-label') || 'found') : null
})()`
console.log('pill:', await cdp.eval(PILL))
const MEASURE = `
(async () => {
const btns = [...document.querySelectorAll('button[aria-label]')]
const pill = btns.find(b => /model/i.test(b.getAttribute('aria-label') || ''))
if (!pill) return JSON.stringify({ error: 'no pill' })
const t0 = performance.now()
pill.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
pill.dispatchEvent(new PointerEvent('pointerup', { bubbles: true }))
pill.click()
// Wait for menu/dialog content to exist AND paint (double rAF after found).
const found = await new Promise(resolve => {
const deadline = performance.now() + 5000
const check = () => {
const menu = document.querySelector('[role="menu"], [role="dialog"] [cmdk-list]')
if (menu && menu.childElementCount > 0) {
requestAnimationFrame(() => requestAnimationFrame(() => resolve(performance.now())))
return
}
if (performance.now() > deadline) { resolve(null); return }
requestAnimationFrame(check)
}
check()
})
const openMs = found ? found - t0 : null
const rows = document.querySelectorAll('[role="menu"] [role="menuitem"], [cmdk-item]').length
// Close: Escape.
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
const menu = document.querySelector('[role="menu"], [role="dialog"]')
menu?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
await new Promise(r => setTimeout(r, 300))
return JSON.stringify({ openMs, rows })
})()
`
const samples = []
for (let i = 0; i < rounds; i++) {
const raw = await cdp.eval(MEASURE, { awaitPromise: true })
const r = JSON.parse(raw)
console.log(`round ${i}:`, r)
if (typeof r.openMs === 'number') samples.push(r.openMs)
await sleep(500)
}
samples.sort((a, b) => a - b)
console.log('\nopen latency ms — min/median/max:',
Math.round(samples[0]), '/', Math.round(samples[Math.floor(samples.length / 2)]), '/', Math.round(samples.at(-1)))
cdp.close()
+38
View File
@@ -0,0 +1,38 @@
// quick probe — read state of the renderer
const list = await (await fetch('http://127.0.0.1:9222/json/list')).json()
const tgt = list.find(t => t.type === 'page' && t.url.startsWith('http'))
console.log('target:', tgt?.url)
if (!tgt) process.exit(1)
const ws = new WebSocket(tgt.webSocketDebuggerUrl)
let id = 0
const pending = new Map()
ws.addEventListener('message', ev => {
const m = JSON.parse(ev.data)
if (m.id != null && pending.has(m.id)) {
pending.get(m.id)(m)
pending.delete(m.id)
}
})
await new Promise(r => ws.addEventListener('open', r))
const send = (method, params = {}) =>
new Promise(r => {
const i = ++id
pending.set(i, r)
ws.send(JSON.stringify({ id: i, method, params }))
})
const r = await send('Runtime.evaluate', {
expression: `({
url: location.href,
title: document.title,
rootChildren: document.getElementById('root')?.children.length ?? 0,
rootInner: (document.getElementById('root')?.innerHTML ?? '').slice(0, 300),
hasComposer: !!document.querySelector('[data-slot="composer-rich-input"]'),
bootStage: (document.querySelector('[data-slot*="boot"]')?.getAttribute('data-slot')) ?? null,
bodyText: document.body.innerText.slice(0, 300),
errorCount: window.__errors?.length ?? 'n/a'
})`,
returnByValue: true
})
console.log('raw:', JSON.stringify(r, null, 2))
ws.close()
+40
View File
@@ -0,0 +1,40 @@
// Probe the cloud shadows thread state — count messages, turn pairs,
// thread height, composer state
const list = await (await fetch('http://127.0.0.1:9222/json/list')).json()
const tgt = list.find(t => t.type === 'page' && t.url.startsWith('http'))
const ws = new WebSocket(tgt.webSocketDebuggerUrl)
let id = 0
const pending = new Map()
ws.addEventListener('message', ev => {
const m = JSON.parse(ev.data)
if (m.id != null && pending.has(m.id)) {
pending.get(m.id)(m)
pending.delete(m.id)
}
})
await new Promise(r => ws.addEventListener('open', r))
const send = (m, p = {}) =>
new Promise(r => {
const i = ++id
pending.set(i, r)
ws.send(JSON.stringify({ id: i, method: m, params: p }))
})
const r = await send('Runtime.evaluate', {
expression: `JSON.stringify({
url: location.href,
title: document.title,
turnPairs: document.querySelectorAll('[data-slot="aui_turn-pair"]').length,
assistantMsgs: document.querySelectorAll('[data-slot="aui_assistant-message-root"]').length,
userMsgs: document.querySelectorAll('[data-message-role="user"], [data-slot="aui_user-message-root"]').length,
totalDomNodes: document.querySelectorAll('*').length,
threadViewportScrollHeight: document.querySelector('[data-slot="aui_thread-viewport"]')?.scrollHeight ?? null,
threadViewportClientHeight: document.querySelector('[data-slot="aui_thread-viewport"]')?.clientHeight ?? null,
threadViewportScrollTop: document.querySelector('[data-slot="aui_thread-viewport"]')?.scrollTop ?? null,
composer: !!document.querySelector('[data-slot="composer-rich-input"]'),
busy: !!document.querySelector('[aria-label*="Stop"]')
})`,
returnByValue: true
})
console.log(JSON.parse(r.result.result.value))
ws.close()
@@ -0,0 +1,60 @@
// CPU-profile one model-picker open.
// node scripts/profile-model-picker.mjs [--port 9222]
import { writeFileSync } from 'node:fs'
import { CDP } from './perf/lib/cdp.mjs'
import { cpuProfileTopSelf } from './perf/lib/stats.mjs'
const port = Number(process.argv.includes('--port') ? process.argv[process.argv.indexOf('--port') + 1] : 9222)
const cdp = await CDP.connect({ port })
await cdp.send('Runtime.enable')
await cdp.send('Profiler.enable')
await cdp.send('Profiler.setSamplingInterval', { interval: 100 })
const OPEN = `
(async () => {
// Reset: close any open menu first.
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
await new Promise(r => setTimeout(r, 250))
const btns = [...document.querySelectorAll('button[aria-label]')]
const pill = btns.find(b => /model/i.test(b.getAttribute('aria-label') || ''))
if (!pill) return -1
const t0 = performance.now()
pill.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
pill.dispatchEvent(new PointerEvent('pointerup', { bubbles: true }))
pill.click()
const found = await new Promise(resolve => {
const deadline = performance.now() + 8000
const check = () => {
const menu = document.querySelector('[role="menu"]')
if (menu && menu.childElementCount > 0) {
requestAnimationFrame(() => requestAnimationFrame(() => resolve(performance.now())))
return
}
if (performance.now() > deadline) { resolve(-1); return }
requestAnimationFrame(check)
}
check()
})
return found < 0 ? -1 : found - t0
})()
`
await cdp.send('Profiler.start')
const openMs = await cdp.eval(OPEN)
const { profile } = await cdp.send('Profiler.stop')
console.log('openMs:', Math.round(openMs))
const out = `/tmp/model-picker-open.cpuprofile`
writeFileSync(out, JSON.stringify(profile))
console.log('wrote', out)
console.log('top self-time (ms):')
for (const r of cpuProfileTopSelf(profile, 20)) {
console.log(` ${r.ms.toFixed(1).padStart(7)} ${r.name.padEnd(44)} ${r.url.split('/').slice(-2).join('/')}:${r.line}`)
}
// Close the menu again.
await cdp.eval(`document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))`)
cdp.close()
+388
View File
@@ -0,0 +1,388 @@
# Profiling renderer typing lag
Workflow for empirically measuring (and fixing) typing/submit lag in the
desktop chat composer.
> **Note (Jul 2026):** the standalone `measure-*` / `profile-*` scripts this
> doc references have been consolidated into the systematized perf harness at
> `scripts/perf/` (`npm run perf`, `npm run perf:serve`). CPU profiling is now a
> `--cpuprofile` flag on any scenario. This doc is kept as an investigation log;
> for the current tooling and the scenario→old-script mapping see
> `scripts/perf/README.md`.
## Quick boot for profiling
Vite 8 + plugin-react 6 has a known issue where the React Fast Refresh
preamble script isn't injected into `index.html`, so opening Electron at
`http://127.0.0.1:5174` throws `$RefreshReg$ is not defined` on every TSX
module and the React tree never mounts. Workaround: run vite with HMR off.
```bash
# Terminal A — start dev server without HMR
cd apps/desktop
node scripts/dev-no-hmr.mjs
# Terminal B — start Electron with CDP exposed
cd apps/desktop
XCURSOR_SIZE=24 HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 \
../../node_modules/.bin/electron --remote-debugging-port=9222 .
```
Terminal C is yours to run the harnesses.
## Harnesses
All zero-dep — Node 24 built-in `WebSocket` + `fetch`.
### Typing latency — `measure-latency.mjs`
Per-keystroke `keypress → next paint` latency, p50/p90/p99/max.
Synthesizes keystrokes via `Input.dispatchKeyEvent` so the run is
reproducible.
```bash
node apps/desktop/scripts/measure-latency.mjs --chars=120 --cps=20
```
Anything > 16ms is a dropped frame. On a freshly-loaded session
(`scripts/click-session.mjs 'Phaser particle'`) we currently see:
| | unpatched | patched |
|---|---|---|
| p50 paint | 1.9 ms | 2.0 ms |
| p90 paint | 3.3 ms | 13.7 ms |
| p99 paint | 16.7 ms | 15.2 ms |
| max paint | 20.5 ms | 30.4 ms |
| >16ms drops | 2/120 | 1/120 |
Roughly even on a quick session — patches don't fix typing latency
under benign synthetic conditions because the existing baseline is
already snappy on synthetic input. The real wins are in the leak counters
(see below). If the user reports typing jank, capture a profile + heap
diff during their actual usage and compare against the synthetic baseline
to identify what condition (long thread, popover open, paste, etc.)
makes the path slow.
### Leak counters — `leak-typing.mjs`
Types N chars per round, clears, force-GCs, captures
`Performance.getMetrics` deltas. Reveals leaked event listeners, heap
drift, document node growth, and forced-layout counts.
```bash
# After clicking into a real session (e.g. via click-session.mjs):
node apps/desktop/scripts/leak-typing.mjs --rounds=8 --chars=200 --cps=50
```
**Real-session numbers (Phaser thread, 8 rounds × 200 chars):**
| | unpatched (HEAD~2) | patched (HEAD) |
|---|---|---|
| jsListeners growth/round | +0 | +0 |
| DOM nodes growth/round | +0 | +0 |
| heap growth/round | ~0 (V8 housekeeping) | ~0 |
| **forced layouts/char** | **7.02** | **2.35** (3× fewer) |
The forced-layout count is the load-bearing number — typing into a real
session was triggering ~7 layouts per character on the unpatched build
(scrollHeight reads + per-px CSS var writes + FadeText scrollWidth reads
all stacking up). After the patches it's down to ~2.35/char, which is
Blink's natural cost for a 1px/char-growing contentEditable and can't
be lowered further without architectural changes.
The initial "+35 listeners/round leak" I called out on the first
unpatched run turned out to be transient warm-up (popovers initializing,
etc.); steady-state listener growth was 0 both before and after.
### CPU profile + heap snapshot — `profile-typing.mjs`
Records a CPU profile while typing, plus before/after heap snapshots so
you can do a comparison diff in Chrome DevTools Memory tab.
```bash
node apps/desktop/scripts/profile-typing.mjs \
--chars=400 --cps=30 --out=/tmp/hermes-typing
# → /tmp/hermes-typing.cpuprofile (open in Chrome DevTools Performance)
# → /tmp/hermes-typing.before.heapsnapshot
# → /tmp/hermes-typing.after.heapsnapshot
```
Loading the cpuprofile: Chrome DevTools → Performance tab → drag the file
in, or VS Code → open the `.cpuprofile` directly.
For heap diff: Chrome DevTools → Memory → Load snapshot → load "before",
then Comparison view → load "after". Sort by `# Delta`. Stay alert for
detached DOM, FiberNodes (unmounted), and listener growth.
## Helpers
- `probe-renderer.mjs` — dump page state (URL, composer mounted?, body text)
- `click-session.mjs <title>` — click a sidebar session by partial title match
- `reload-renderer.mjs` — force Page.reload via CDP (no HMR available)
- `dump-state.mjs` — richer state dump (thread message count, sticky session, etc.)
- `probe-console.mjs` — dump recent console errors / exceptions
## Findings
See commit message for `apps/desktop/src/app/chat/composer/index.tsx`
edits. Three changes:
1. **Per-keystroke `scrollHeight` read removed.** The expansion useEffect
used to read `editorRef.current.scrollHeight` on every draft change
(forces synchronous layout). Replaced with a `draft.length > 60`
heuristic; the ResizeObserver catches anything the heuristic misses.
2. **Bucketed CSS custom-property writes.** `syncComposerMetrics`
used to `setProperty('--composer-measured-height', height + 'px')`
on every observed resize, invalidating computed style for the whole
tree. Now writes only when the height crosses an 8 px bucket, so
typing in a fixed-height row produces no style invalidation at all.
3. **Removed dead `$composerDraft` → `aui.composer().setText` round-trip.**
Nothing outside the composer subscribed to `$composerDraft` (verified
via grep). The two useEffects that pushed draft → store and store →
composer were pure overhead per keystroke. `reconcileComposerTerminalSelections`
was also called per keystroke; can be deferred to submit time (it's a
stale-pruning step, not a correctness one — `terminalContextBlocksFromDraft`
walks the current text directly at submit and ignores stale labels).
4. **`refreshTrigger` fast-bails when no `@`/`/` in draft.** Previously
`textBeforeCaret()` did `range.toString()` (O(n)) on every keystroke
even when no trigger char was present.
The biggest win is the listener leak in (3) — without it, each round of
typing leaked ~35 event listeners until a steady state.
## Submit / TTFT stall (open)
User reports a perceived stall *after* Enter, before the assistant starts
streaming. `scripts/measure-submit.mjs` measures
`enter → composer-cleared → user-message-rendered → first-paint`. The
script triggers a real prompt submission, so use it on a throwaway
session. Not enabled in CI.
## Streaming "5fps" investigation (May 21, 2026)
User complaint: "the streaming must bring fps to like 5? lol" — felt
hitches during assistant streaming on long threads.
### Tooling added
- **`src/app/chat/perf-probe.tsx`** — dev-only side-effect import (guarded by
`import.meta.env.MODE !== 'production'` in `main.tsx`). Attaches two
helpers to `window`:
- `__PERF_PROBE__` — React `<Profiler>` recorder. Currently inert because
Vite is serving the production React build (see "Vite dev-build issue"
below); kept for when that's fixed.
- `__PERF_DRIVE__` — synthetic stream driver. Pushes tokens through the
live `$messages` atom at a fixed cadence, so the assistant-ui runtime,
incremental repository, Streamdown markdown renderer, and React commit
pipeline all see the same workload they'd see from a real LLM stream —
but with no LLM call (and no credit cost).
- **`scripts/measure-synthetic-stream.mjs`** — drives `__PERF_DRIVE__`,
records rAF frame intervals, `PerformanceObserver({entryTypes:['longtask']})`
entries, `MutationObserver` cadence on the live message, and optional
type-while-streaming keystroke latency.
- **`scripts/profile-synth-stream.mjs`** — CPU profile during a synthetic
stream; writes a `.cpuprofile` (open in Chrome DevTools Performance panel)
and a top-30 self-time table.
- **`scripts/measure-real-stream.mjs`** — same harness as the synthetic but
fires a real LLM prompt. Use when you have credits and want to confirm
the synthetic predictions hold.
- **`scripts/profile-real-stream.mjs`** — CPU profile over the duration of
a real LLM stream.
Helpers: `scripts/eval.mjs` (one-shot CDP eval), `scripts/reload.mjs`
(hard reload renderer over CDP).
### Findings
Measured on the Cloud Shadows session (7 turns, ~11k px scrollHeight) and
the 34 MB session `session_20260514_215353_fe0ac8.json` (110 FadeText
instances, lots of historical tool calls).
| metric | Cloud Shadows | 34 MB session |
|---|---|---|
| avgFps (60 tok/sec, 5s) | 60.0 | 58.6 |
| frame p50 / p95 / p99 (ms) | 16.7 / 18.0 / 21.1 | 16.6 / 25.6 / 31.4 |
| max frame (ms) | 31.1 | 97-127 (varies) |
| longtasks per 5s window | 0 | 1-2, 75-127 ms |
| type-while-stream p95 latency (ms) | 17 | — |
A single real-LLM stream on Cloud Shadows (gpt-4o-mini, 39s window) saw
12 longtasks totalling 1.26 s — same cadence the synthetic predicted
(~1 hitch per 3.25 s, max 123 ms). So the **synthetic stream is a faithful
proxy for the real one** and is fine for iterating on fixes without paying
for tokens.
### CPU profile during streaming (synthetic, markdown content)
Top self-time costs (5 s window, 400 tokens at 125 tok/s, markdown chunks):
| ms (self) | function | source |
|---|---|---|
| 260 | `bn$1` | `chunk-BO2N…js:20003` (micromark tokenize) |
| 249 | `m$1` | `chunk-BO2N…js:19949` (micromark) |
| 128 | `compile` | `chunk-BO2N…js:21884` (mdast → hast compile) |
| 73 | FadeText body | `components/ui/fade-text.tsx` |
| 62 | `parser` | `chunk-BO2N…js:22680` |
| 49 | `fromThreadMessageLike` | `@assistant-ui/internal` |
That `chunk-BO2N2NFS` is the vendored bundle containing `micromark`,
`mdast-util-from-markdown`, `mdast-util-to-hast`, `rehype-raw`,
`hast-util-sanitize`, etc. — i.e. **Streamdown's markdown pipeline,
re-parsing the entire growing assistant message on every token append**.
Cost scales linearly with message length.
Compare plain-text (no markdown) — the `chunk-BO2N…` entries drop out
of the top 30 entirely; total work per 5 s window halves.
### Fix landed: `FadeText` memo
`FadeText` is used in `tool-fallback.tsx` (110 instances on a tool-heavy
thread). Before: each parent re-render during streaming triggered a
`useEffect([children])` that forced a `scrollWidth` layout read — even
when the title text was unchanged. The `useResizeObserver` already covers
the genuine resize case, so the effect was strictly redundant.
After: wrapped in `React.memo` with a custom comparator that compares
`children` (scalar fast-path), `className`, `fadeWidth`, and `style`
field-by-field. Verified via temporary render counter:
**122 renders during a 2 s synthetic stream vs ~11 000 without memo**
(110 instances × ~100 stream updates). Doesn't move the longtask needle
on its own — Streamdown dwarfs it — but eliminates a class of forced
layouts and removes a steady CPU floor.
### Also landed: `MarkdownText` plugins memo + upstream flush floor
Two smaller follow-ups in the same investigation:
1. **`MarkdownText` `plugins` object useMemo'd.** The inline
`plugins={{ math: mathPlugin, ...(isStreaming ? {} : { code }) }}`
was constructing a new object on every render, which churns
`<Streamdown>`'s outer memo and forces its internal `rehypePlugins` /
`remarkPlugins` arrays to rebuild. CPU profile after the change shows
`parser` self-time dropping out of the top 10, `compile` cut roughly
in half, and `bn$1` / `m$1` (micromark internals) dropping off the
top entries.
2. **`use-message-stream.scheduleDeltaFlush` got a real minimum floor.**
Previously the rAF-only path effectively meant "at most one flush per
frame," but at typical LLM token rates of 30-80 tok/sec each token
arrives slower than rAF cadence and gets its own React commit. With
`STREAM_DELTA_FLUSH_MS = 33` (two frames) and a `lastFlushAt`-tracked
floor, slower streams now coalesce ~2 tokens per commit, halving
markdown re-parses. React's auto-batching already covers part of this
probabilistically; the floor makes the batching deterministic so the
max-longtask number tightens up.
A/B on the 34 MB session, 300 tokens at 50 tok/sec, markdown chunks
(3 trials each):
| | avgFps | p99 frame | LTs/5s | max LT | mutations |
|---|---|---|---|---|---|
| no throttle | 54.0 | 38 ms | 2.0 | 145 ms | varies (2-112) |
| 33 ms throttle | 54.3 | 41 ms | 1.7 | 110 ms | ~135 |
Modest. `inter-mutation` p50 tightens from 22-28 ms to a clean 33 ms,
which is what you'd expect from a deterministic floor.
### Also landed: `useDeferredValue` at the streamdown-text boundary
The longtask CPU was unavoidable inside the block-memo pattern — the live
tail re-parses every commit, scales linearly with current length, and
nothing about Streamdown's architecture changes that without forking. The
fix is to stop having that work *block* the main thread.
`<DeferStreamingText>` in `markdown-text.tsx` is a 12-line wrapper that
reads the message-part state via `useMessagePartText`, runs it through
`useDeferredValue`, and re-publishes via assistant-ui's
`<TextMessagePartProvider>`. The inner `StreamdownTextPrimitive` reads the
deferred value through the normal `useMessagePartText` hook — no fork,
no internal-path imports, fully on the assistant-ui public API.
What React's concurrent scheduler now does:
- When a new token arrives mid-render, the in-flight deferred render
is abandoned and a fresh one starts with the latest text.
- When the main thread has urgent work (typing, scroll, layout), the
Streamdown render gets deprioritized — input stays responsive even
while a 100 ms parse is queued.
Streamdown already uses `useTransition` internally for its block-array
setState; `useDeferredValue` here just lifts the deferral all the way up
to the consumer text boundary, so the whole pipeline — preprocess,
block split, repair, parse, render — runs at low priority during streaming.
This is the industry-standard approach (see
[Streamdown architecture analysis](https://tigerabrodi.blog/how-to-build-a-performant-ai-markdown-renderer)
and Chrome's [LLM-response render best practices](https://developer.chrome.google.cn/docs/ai/render-llm-responses)).
A/B on the 34 MB session, 300 tokens at 50 tok/sec, markdown chunks
(four trials each, prod-throttle (33 ms) on for both):
| | avgFps | p99 frame | LTs / 5 s | max LT | typing p95 |
|---|---|---|---|---|---|
| pre-defer | 54.3 | 41 ms | 1.7 | 110 ms | ~17 ms |
| **post-defer** | **58.5** | **31 ms** | 2.0 | 117 ms | 14-18 ms |
Longtask count and max LT are unchanged — `useDeferredValue` doesn't
reduce CPU, only its priority. The avgFps lift and p99 frame drop are
the proof that the existing CPU is no longer blocking 60 fps cadence:
when React can defer the parse, frames stay clean. One particularly
clean run logged **MUTATIONS=0** — React skipped every intermediate
text state and only committed the final one, the textbook
useDeferredValue behaviour.
### Not fixed: Streamdown markdown re-parse cost (the elephant)
Total CPU spent in micromark/mdast/hast pipeline per 5 s window is still
the same ~700 ms. With `useDeferredValue` that work no longer blocks
input, but if you watch a CPU profile you'll see the same hot functions
(`Tn$1`, `bn$1`, `m$1`, `parser`, `compile`).
The path to actually *reduce* that cost (not just defer it) is to
replace the parser with a state machine like
[Flowdown](https://github.com/Atomics-hub/flowdown) — process each
character exactly once, emit DOM ops directly, no re-parse of the prefix
on every token. Claimed ~2,000× over `marked`. Trades: not a
`react-markdown`-compatible API, no rehype security pipeline, would
require replacing Streamdown wholesale. Worth investigating only if
even the deferred work shows up in user-perceptible ways (e.g.
trackpad-scrolling a stream-in-progress stutters).
The synthetic harness now mirrors the real upstream pipeline via the
`flushMinMs` option in `__PERF_DRIVE__.stream({ flushMinMs: 33 })`, so
future Streamdown / Flowdown experiments can A/B without LLM credit cost.
The synthetic numbers tracked the one real-LLM run we caught within
noise, so it's a reliable proxy.
Possible approaches (none implemented here):
1. **Coalesce/throttle Streamdown updates** — render at most every 32 ms
instead of every set-state. Reduces parses but doesn't reduce
per-parse cost; trades latency for smoothness.
2. **Memoize per-prefix** — diff the new text against the prior parsed
version; only re-parse the changed suffix.
3. **Render in stable segments** — close-form historical paragraphs as
immutable React nodes; only the live tail goes through markdown each
token. Probably the highest-impact change but requires forking or
patching `@assistant-ui/react-streamdown`.
4. **Move parsing to a Web Worker** — main thread no longer blocks on
markdown. Largest surgery; requires double-buffered hast.
### Vite dev-build issue (separate)
`http://127.0.0.1:5174/node_modules/.vite/deps/react.js` resolves to
`react/cjs/react.production.js`, and `react-dom_client.js`
`react-dom-client.production.js`. As a result:
- `<React.Profiler>` `onRender` is never called (production build is a
no-op).
- `import.meta.env.DEV` is `false`, `PROD` is `true` even under `vite dev`
(hence `MODE !== 'production'` as the workaround in `main.tsx`).
- All the React 19 dev-only warnings/devtools backend hooks are absent.
Root cause likely sits in `vite.config.ts` aliasing + dedupe + Vite 8's
new `optimizeDeps` defaults. Worth a separate fix pass — when it's
resolved, the `<PerfProbe>` blocks in `perf-probe.tsx` become useful
(per-id commit timings) instead of inert.
+22
View File
@@ -0,0 +1,22 @@
// rebuild-native.mjs
import { rebuild } from '@electron/rebuild'
import { resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import { isMain } from './utils.mjs'
import packageJson from '../package.json' with { type: 'json' }
const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..')
export async function rebuildNodePty({ arch = process.arch } = {}) {
await rebuild({
buildPath: projectRoot, // where node_modules lives
electronVersion: packageJson.devDependencies.electron.replace('^', ''),
arch,
onlyModules: ['node-pty'],
force: true
})
}
if (isMain(import.meta.url)) {
const [arch] = process.argv.slice(2)
await rebuildNodePty({ arch })
}
+25
View File
@@ -0,0 +1,25 @@
// Reload the renderer via CDP so it picks up the latest from Vite.
const list = await (await fetch('http://127.0.0.1:9222/json/list')).json()
const tgt = list.find(t => t.type === 'page' && t.url.startsWith('http'))
const ws = new WebSocket(tgt.webSocketDebuggerUrl)
let id = 0
const pending = new Map()
ws.addEventListener('message', ev => {
const m = JSON.parse(ev.data)
if (m.id != null && pending.has(m.id)) {
pending.get(m.id)(m)
pending.delete(m.id)
}
})
await new Promise(r => ws.addEventListener('open', r))
const send = (method, params = {}) =>
new Promise(r => {
const i = ++id
pending.set(i, r)
ws.send(JSON.stringify({ id: i, method, params }))
})
await send('Page.enable')
await send('Page.reload', { ignoreCache: true })
console.log('reload requested')
await new Promise(r => setTimeout(r, 200))
ws.close()
+36
View File
@@ -0,0 +1,36 @@
// Hard reload the Electron renderer over CDP. Vite-no-HMR mode means edits
// don't auto-apply — call this after editing source.
const targets = await (await fetch('http://127.0.0.1:9222/json')).json()
const t = targets.find((t) => t.url.includes('5174'))
if (!t) {
console.error('renderer not found')
process.exit(1)
}
const ws = new WebSocket(t.webSocketDebuggerUrl)
let id = 0
const pending = new Map()
ws.addEventListener('message', (ev) => {
const m = JSON.parse(ev.data)
if (pending.has(m.id)) {
pending.get(m.id)(m)
pending.delete(m.id)
}
})
await new Promise((r) => ws.addEventListener('open', r))
const send = (method, params = {}) =>
new Promise((res) => {
const i = ++id
pending.set(i, res)
ws.send(JSON.stringify({ id: i, method, params }))
})
await send('Page.reload', { ignoreCache: true })
console.log('reload sent')
// Wait for new doc.
await new Promise((r) => setTimeout(r, 2500))
const r = await send('Runtime.evaluate', {
expression: 'JSON.stringify({ hasProbe: !!window.__PERF_PROBE__, composer: !!document.querySelector("[contenteditable=true]"), url: location.hash })',
returnByValue: true,
})
console.log(r.result.result.value)
ws.close()
@@ -0,0 +1,67 @@
// Resolve electronDist at runtime (#38673, #47917): electron-builder 26.8.x can
// re-unpack a broken Electron.app; reusing the installed dist dodges that.
// npm workspace hoisting is non-deterministic — require.resolve finds electron
// wherever it landed. Dist present → -c.electronDist=<abs>/dist; absent → let
// electron-builder fetch via @electron/get (electronVersion + ELECTRON_MIRROR).
import fs from "node:fs"
import path from "node:path"
import { spawnSync } from "node:child_process"
import { createRequire } from "node:module"
const require = createRequire(import.meta.url)
function electronDistDir() {
try {
return path.join(path.dirname(require.resolve("electron/package.json")), "dist")
} catch {
return null
}
}
function distBinary(dist) {
if (process.platform === "darwin") {
return path.join(dist, "Electron.app", "Contents", "MacOS", "Electron")
}
if (process.platform === "win32") {
return path.join(dist, "electron.exe")
}
return path.join(dist, "electron")
}
function electronBuilderCli() {
const pkgJson = require.resolve("electron-builder/package.json")
const bin = require(pkgJson).bin
const rel = typeof bin === "string" ? bin : bin["electron-builder"]
return path.join(path.dirname(pkgJson), rel)
}
const dist = electronDistDir()
// Local `hermes desktop` builds only ever package (--dir or dist), never
// publish a GitHub release — no CI workflow drives this script. But the npm
// lifecycle env sets CI=1 (so esbuild's postinstall doesn't try interactive
// animations), and electron-builder treats CI=1 as a signal to implicitly
// resolve a publish target. That resolution reads <projectDir>/.git/config
// directly — projectDir here is apps/desktop, which has no .git of its own
// (only the repo root does) and no "repository" field in its package.json —
// so it fails with "Cannot detect repository by .git/config". Pin publish to
// "never" so electron-builder skips that lookup entirely.
const args = ["--publish", "never"]
if (dist && fs.existsSync(distBinary(dist))) {
args.push(`-c.electronDist=${dist}`)
} else {
console.warn(
"[run-electron-builder] no local electron dist; electron-builder will fetch " +
"via @electron/get (electronVersion + ELECTRON_MIRROR)."
)
}
args.push(...process.argv.slice(2))
const result = spawnSync(process.execPath, [electronBuilderCli(), ...args], {
stdio: "inherit",
})
if (result.error) {
console.error(`[run-electron-builder] spawn failed: ${result.error.message}`)
process.exit(1)
}
process.exit(result.status == null ? 1 : result.status)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,171 @@
import assert from 'node:assert/strict'
import { existsSync, writeFileSync } from 'node:fs'
import test from 'node:test'
import { join } from 'node:path'
import {
ReproductionError,
classify,
pairedSoftSignal,
resultForError,
validateSummary,
waitFor,
waitForPredicate,
waitForResponsive,
withTemporarySandbox,
withTimeout
} from './run-short-session-hang-repro.mjs'
const result = (outcome, latency = 10) => ({
hardFailure: outcome !== 'not-reproduced',
maxGapMs: latency,
maxOperationMs: latency,
outcome
})
test('separates harness errors from reproduction timeouts', () => {
const harness = resultForError(new Error('fixture mismatch'))
const reproduced = resultForError(new ReproductionError('renderer operation exceeded 5000ms'))
assert.equal(harness.outcome, 'harness-error')
assert.equal(harness.maxGapMs, null)
assert.equal(harness.maxOperationMs, null)
assert.equal(reproduced.outcome, 'reproduced')
})
test('preserves timeout semantics through the actual nested helpers', async () => {
const immediate = await withTimeout(Promise.reject(new Error('precondition')), 30, 'outer', ReproductionError).catch(
error => error
)
assert.equal(resultForError(immediate).outcome, 'harness-error')
const stalledCdp = { eval: async () => false }
const inner = await withTimeout(
waitFor(stalledCdp, 'false', 10, 'inner product operation', ReproductionError),
50,
'outer product operation',
ReproductionError
).catch(error => error)
assert.ok(inner instanceof ReproductionError)
assert.match(inner.message, /inner product operation/)
const outer = await withTimeout(
waitFor(stalledCdp, 'false', 50, 'inner product operation', ReproductionError),
10,
'outer product operation',
ReproductionError
).catch(error => error)
assert.ok(outer instanceof ReproductionError)
assert.match(outer.message, /outer product operation/)
const nearDeadline = await withTimeout(
new Promise(resolve => setTimeout(() => resolve('responsive'), 20)),
50,
'responsive operation',
ReproductionError
)
assert.equal(nearDeadline, 'responsive')
})
test('distinguishes a responsive false condition from a stalled renderer evaluation', { timeout: 2_000 }, async () => {
let transientEvaluations = 0
await waitForResponsive(
{
eval: async () => {
transientEvaluations += 1
if (transientEvaluations === 1) {
throw new Error('execution context was destroyed')
}
return true
}
},
'true',
500,
'transient condition',
50
)
assert.equal(transientEvaluations, 2)
await assert.rejects(
waitForResponsive({ eval: async () => false }, 'false', 10, 'responsive condition', 1_000),
error => !(error instanceof ReproductionError) && /renderer remained responsive/.test(error.message)
)
await assert.rejects(
waitForResponsive({ eval: () => new Promise(() => {}) }, 'false', 50, 'stalled condition', 10),
ReproductionError
)
await assert.rejects(
waitForPredicate(() => false, 10, 'provider request'),
/provider request/
)
})
test('invalidates a target when warmup or measured runs have harness errors', () => {
const passing = Array.from({ length: 5 }, () => result('not-reproduced'))
assert.equal(classify(passing, result('harness-error')).classification, 'invalid')
assert.equal(
classify([result('harness-error'), ...passing.slice(1)], result('not-reproduced')).classification,
'invalid'
)
assert.equal(classify(passing, result('not-reproduced')).classification, 'not-reproduced')
assert.equal(
classify(
[result('reproduced'), result('reproduced'), result('reproduced'), result('reproduced'), passing[0]],
passing[0]
).classification,
'reproduced'
)
})
test('suppresses soft-signal comparisons when a run is invalid or reproduced', () => {
const passing = Array.from({ length: 5 }, () => result('not-reproduced', 10))
assert.equal(pairedSoftSignal([], passing).reason, 'insufficient-runs')
assert.equal(pairedSoftSignal(passing, []).reason, 'insufficient-runs')
assert.equal(pairedSoftSignal(passing, passing).reason, undefined)
assert.equal(pairedSoftSignal([result('harness-error'), ...passing.slice(1)], passing).reason, 'hard-or-invalid-run')
assert.equal(pairedSoftSignal([result('reproduced'), ...passing.slice(1)], passing).reason, 'hard-or-invalid-run')
})
test('rejects contradictory summary semantics', () => {
const passing = Array.from({ length: 5 }, () => result('not-reproduced'))
const classification = classify(passing, result('not-reproduced'))
const target = { ...classification, runs: passing, warmup: result('not-reproduced') }
const summary = { baseline: target, candidate: target, invalid: false }
assert.doesNotThrow(() => validateSummary(summary, 5))
assert.throws(
() => validateSummary({ ...summary, baseline: { ...target, classification: 'reproduced' } }, 5),
/inconsistent baseline summary classification/
)
assert.throws(
() =>
validateSummary(
{
...summary,
baseline: { ...target, runs: [{ ...passing[0], hardFailure: true }, ...passing.slice(1)] }
},
5
),
/inconsistent baseline run outcome and hardFailure/
)
})
test('removes the exact temporary sandbox when the run body throws', async () => {
let sandbox
await assert.rejects(
withTemporarySandbox('cleanup-test', path => {
sandbox = path
writeFileSync(join(path, 'diagnostic.txt'), 'temporary')
throw new Error('teardown report failed')
}),
/teardown report failed/
)
assert.ok(sandbox)
assert.equal(existsSync(sandbox), false)
})
+91
View File
@@ -0,0 +1,91 @@
#!/usr/bin/env node
// set-exe-identity.mjs — stamp the Hermes icon + version metadata onto the
// built Hermes.exe using rcedit, completely decoupled from electron-builder's
// signing path.
//
// WHY THIS EXISTS
// ---------------
// apps/desktop/package.json sets build.win.signAndEditExecutable=false. That
// flag is load-bearing: turning electron-builder's own exe-editing ON also
// re-enables its signtool step, which fetches winCodeSign-2.6.0.7z, whose
// macOS symlinks crash 7-Zip on non-admin Windows (no Developer Mode = no
// SeCreateSymbolicLinkPrivilege). That is an unfixable dead end — we do NOT
// try to extract winCodeSign.
//
// The cost of disabling signAndEditExecutable is that electron-builder also
// skips rcedit, so the unpacked Hermes.exe keeps the stock Electron icon and
// "Electron" taskbar name. This script restores the icon + identity by calling
// rcedit DIRECTLY. rcedit is a pure PE resource editor: no signing, no certs,
// no winCodeSign, no symlinks.
//
// HOW IT RUNS
// -----------
// Primarily as an electron-builder `afterPack` hook (scripts/after-pack.mjs),
// so EVERY packed build — first install, `hermes desktop`, the installer's
// --update rebuild, or a dev's manual `npm run pack` — gets a branded exe from
// one place. Previously this stamp lived only in install.ps1, so the update
// path (which rebuilds via `hermes desktop --build-only`, never install.ps1)
// shipped a stock "Electron" exe. Keeping it in afterPack closes that gap.
//
// Also runnable standalone for ad-hoc re-stamping:
// node scripts/set-exe-identity.mjs <path-to-Hermes.exe>
//
// Exits 0 on success, non-zero on failure when run as a CLI. As a hook,
// stampExeIdentity() resolves on success and rejects on failure; the caller
// (after-pack.mjs) swallows the rejection so a stamp failure never fails an
// otherwise-good build (worst case: stock icon, not a broken app).
import { resolve, join } from 'node:path'
import { existsSync, readFileSync } from 'node:fs'
import { rcedit } from 'rcedit'
import { isMain } from './utils.mjs'
// Stamp the Hermes icon + identity onto `exe`. Resolves on success, throws on
// failure. `desktopRoot` defaults to this script's package root so the icon and
// the rcedit dependency resolve regardless of cwd.
async function stampExeIdentity(exe, desktopRoot = resolve(import.meta.dirname, '..')) {
if (!exe || !existsSync(exe)) {
throw new Error(`target exe not found: ${exe}`)
}
// Icon lives at apps/desktop/assets/icon.ico
const icon = join(desktopRoot, 'assets', 'icon.ico')
if (!existsSync(icon)) {
throw new Error(`icon not found: ${icon}`)
}
console.log(`[set-exe-identity] stamping ${exe}`)
console.log(`[set-exe-identity] icon: ${icon}`)
const manifest = JSON.parse(readFileSync(join(desktopRoot, 'package.json'), 'utf8'))
await rcedit(exe, {
icon,
'file-version': `${manifest.version.split('-')[0]}.0`,
'product-version': `${manifest.version.split('-')[0]}.0`,
'version-string': {
ProductName: manifest.productName,
FileDescription: manifest.productName,
CompanyName: 'AITURK / TurkServis',
LegalCopyright: manifest.build.copyright
}
})
console.log('[set-exe-identity] done — AITURK icon + identity stamped')
}
export { stampExeIdentity }
// CLI entry point: `node scripts/set-exe-identity.mjs <exe>`.
if (isMain(import.meta.url)) {
const exe = process.argv[2]
if (!exe) {
console.error('[set-exe-identity] usage: set-exe-identity.mjs <path-to-exe>')
process.exit(2)
}
stampExeIdentity(exe).catch(err => {
console.error(`[set-exe-identity] ${err.message}`)
process.exit(1)
})
}
+623
View File
@@ -0,0 +1,623 @@
#!/usr/bin/env node
// stage-native-deps.mjs — stages node-pty's native runtime dependencies
//
// Usage:
// node scripts/stage-native-deps.mjs # host platform/arch
// node scripts/stage-native-deps.mjs win32 arm64 # explicit target
//
// Also exported as `stageNodePty({ platform, arch })` for use from
// before-pack.mjs, where electron-builder gives you the real per-target
// platform/arch during multi-arch builds.
import { createRequire } from 'node:module'
import { fileURLToPath } from 'node:url'
import { dirname, resolve, join } from 'node:path'
import {
chmodSync,
cpSync,
existsSync,
mkdirSync,
readdirSync,
readFileSync,
rmSync,
writeFileSync
} from 'node:fs'
import { spawnSync } from 'node:child_process'
import { isMain } from './utils.mjs'
const here = dirname(fileURLToPath(import.meta.url))
const projectRoot = resolve(here, '..')
const require = createRequire(import.meta.url)
function makeExecutable(filePath) {
chmodSync(filePath, 0o755)
}
function patchUnixTerminalAsarPaths(destRoot) {
const filePath = join(destRoot, 'lib', 'unixTerminal.js')
if (!existsSync(filePath)) return
const source = readFileSync(filePath, 'utf8')
const patched = source
.replace(
"helperPath = helperPath.replace('app.asar', 'app.asar.unpacked');",
"helperPath = helperPath.replace(/app\\.asar(?!\\.unpacked)/, 'app.asar.unpacked');"
)
.replace(
"helperPath = helperPath.replace('node_modules.asar', 'node_modules.asar.unpacked');",
"helperPath = helperPath.replace(/node_modules\\.asar(?!\\.unpacked)/, 'node_modules.asar.unpacked');"
)
if (patched !== source) {
writeFileSync(filePath, patched)
}
}
/**
* Locate node-pty's package root via real module resolution, so this
* works whether it's hoisted to a workspace root or local to this app.
*/
function resolveNodePtyRoot() {
const pkgJsonPath = require.resolve('node-pty/package.json', {
paths: [projectRoot]
})
return dirname(pkgJsonPath)
}
function copyGlobByExt(srcDir, destDir, extensions) {
if (!existsSync(srcDir)) return
mkdirSync(destDir, { recursive: true })
for (const entry of readdirSync(srcDir, { withFileTypes: true })) {
if (entry.isDirectory()) {
copyGlobByExt(join(srcDir, entry.name), join(destDir, entry.name), extensions)
continue
}
if (extensions.some((ext) => entry.name.endsWith(ext))) {
mkdirSync(destDir, { recursive: true })
cpSync(join(srcDir, entry.name), join(destDir, entry.name))
}
}
}
/**
* Copies the locally-compiled build/Release output (used when no prebuild
* was available and node-pty was built from source for the host machine).
*
* Filters by name/pattern rather than extension only: macOS builds a
* separate `spawn-helper` executable (no file extension) that
* lib/unixTerminal.js requires at a fixed relative path. Filtering this
* directory by ['.node'] silently drops it — the package then looks
* fine, ships fine, and crashes the first time a terminal is spawned.
* Directories are copied wholesale to also cover any nested native
* payload (e.g. a conpty/ subfolder some build layouts produce).
*/
function copyBuildRelease(srcDir, destDir) {
if (!existsSync(srcDir)) return
mkdirSync(destDir, { recursive: true })
for (const entry of readdirSync(srcDir, { withFileTypes: true })) {
if (entry.isDirectory()) {
cpSync(join(srcDir, entry.name), join(destDir, entry.name), { recursive: true })
continue
}
if (entry.name === 'spawn-helper' || /\.(node|dll|exe)$/.test(entry.name)) {
const destFile = join(destDir, entry.name)
cpSync(join(srcDir, entry.name), destFile)
if (entry.name === 'spawn-helper') {
makeExecutable(destFile)
}
}
}
}
// ─── binary classification ───────────────────────────────────────────
//
// .node files are shared libraries in the target platform's native binary
// format. By reading the first few bytes (magic) we can determine which
// platform a given .node was compiled for, without shelling out to `file`.
//
// ELF (\x7fELF) → linux
// Mach-O 32-bit BE (feedface) → darwin
// Mach-O 64-bit BE (feedfacf) → darwin
// Mach-O 32-bit LE (cefaedfe — CIGAM) → darwin
// Mach-O 64-bit LE (cffaedfe — CIGAM_64) → darwin
// Fat/Universal BE (cafebabe) → darwin
// Fat/Universal LE (bebafeca — FAT_CIGAM) → darwin
// PE (MZ DOS header) → win32
//
// Mach-O and Fat binaries are stored on disk in the host's native byte
// order. On x64/arm64 Darwin (every Apple Silicon + every Intel Mac that
// ships node-pty prebuilds) that is little-endian, so the on-disk magic is
// the CIGAM byte-swapped form, NOT the big-endian MH_MAGIC form. Checking
// only the BE constants misclassifies every real Darwin prebuild as unknown.
//
// Exported for unit testing.
/**
* Classify a native binary's target platform from its magic bytes.
* Returns `'linux'`, `'darwin'`, `'win32'`, or `null` if unrecognized
* or the file cannot be read.
*/
export function classifyNativeBinary(filePath) {
let buf
try {
buf = readFileSync(filePath, { start: 0, end: 63 }) // first 64 bytes
} catch {
return null
}
if (buf.length < 4) return null
// ELF: \x7f E L F
if (buf[0] === 0x7f && buf[1] === 0x45 && buf[2] === 0x4c && buf[3] === 0x46) {
return 'linux'
}
// Mach-O 32-bit (big-endian / MH_MAGIC): feedface
if (buf[0] === 0xfe && buf[1] === 0xed && buf[2] === 0xfa && buf[3] === 0xce) {
return 'darwin'
}
// Mach-O 64-bit (big-endian / MH_MAGIC_64): feedfacf
if (buf[0] === 0xfe && buf[1] === 0xed && buf[2] === 0xfa && buf[3] === 0xcf) {
return 'darwin'
}
// Mach-O 32-bit (little-endian / MH_CIGAM): cefaedfe
if (buf[0] === 0xce && buf[1] === 0xfa && buf[2] === 0xed && buf[3] === 0xfe) {
return 'darwin'
}
// Mach-O 64-bit (little-endian / MH_CIGAM_64): cffaedfe
if (buf[0] === 0xcf && buf[1] === 0xfa && buf[2] === 0xed && buf[3] === 0xfe) {
return 'darwin'
}
// Fat/Universal binary (big-endian / FAT_MAGIC): cafebabe
if (buf[0] === 0xca && buf[1] === 0xfe && buf[2] === 0xba && buf[3] === 0xbe) {
return 'darwin'
}
// Fat/Universal binary (little-endian / FAT_CIGAM): bebafeca
if (buf[0] === 0xbe && buf[1] === 0xba && buf[2] === 0xfe && buf[3] === 0xca) {
return 'darwin'
}
// PE: MZ DOS header
if (buf[0] === 0x4d && buf[1] === 0x5a) {
return 'win32'
}
return null
}
/**
* Scan the staged destination tree for .node files and verify each one's
* binary platform matches the requested target. Throws on any mismatch.
*
* This is the fail-closed safety net: even if a prebuild or build/Release
* somehow slipped through with the wrong platform, this catches it before
* the package ships a broken native binary to users.
*/
function validateStagedBinaries(destRoot, targetPlatform) {
const mismatches = []
function scan(dir, relPrefix) {
if (!existsSync(dir)) return
for (const entry of readdirSync(dir, { withFileTypes: true })) {
if (entry.isDirectory()) {
scan(join(dir, entry.name), `${relPrefix}${entry.name}/`)
continue
}
if (!entry.name.endsWith('.node')) continue
const fullPath = join(dir, entry.name)
const classified = classifyNativeBinary(fullPath)
if (classified !== targetPlatform) {
mismatches.push({ file: `${relPrefix}${entry.name}`, classified, expected: targetPlatform })
}
}
}
scan(join(destRoot, 'prebuilds'), 'prebuilds/')
scan(join(destRoot, 'build', 'Release'), 'build/Release/')
if (mismatches.length > 0) {
throw new Error(
`[stage-native-deps] native binary platform mismatch (target=${targetPlatform}):\n` +
mismatches
.map((m) => ` ${m.file}: expected ${m.expected}, got ${m.classified ?? 'unknown'}`)
.join('\n') +
`\nRefusing to stage a binary compiled for the wrong platform.`
)
}
}
/**
* Stage node-pty's native runtime dependencies into `destRoot`.
*
* Exported separately from `stageNodePty` so tests can supply a fake
* node-pty source tree without going through real module resolution.
*
* Strategy (fail-closed):
*
* 1. Copy the matching prebuild (`prebuilds/<platform>-<arch>/`) if present.
* 2. Copy `build/Release/` **only when the target matches the host** —
* build/Release contains a binary compiled for the host's platform/arch,
* so staging it for a different target ships a broken app.
* 3. If no native binary was staged:
* - Same platform as host, different arch → run `electron-rebuild --arch`.
* - Different platform from host → throw (cannot cross-compile native
* modules; build on the target platform or provide a prebuild).
* 4. Validate every staged `.node` file's binary platform matches the target.
*/
export function stageNodePtyInto(srcRoot, destRoot, { platform = process.platform, arch = process.arch } = {}) {
const hostMatch = platform === process.platform && arch === process.arch
rmSync(destRoot, { recursive: true, force: true })
mkdirSync(destRoot, { recursive: true })
// package.json — needed so `require('node-pty')` resolves the package
// (reads "main") rather than treating it as a directory with no entry.
cpSync(join(srcRoot, 'package.json'), join(destRoot, 'package.json'))
// lib/**/*.js — the JS surface node-pty's `main` points into.
copyGlobByExt(join(srcRoot, 'lib'), join(destRoot, 'lib'), ['.js'])
patchUnixTerminalAsarPaths(destRoot)
// prebuilds/<platform>-<arch>/* — the prebuild-install payload for the
// *target* we're packaging, not necessarily the host running this script.
// Explicit extensions only, to skip the ~25MB of Windows .pdb symbols
// prebuild-install bundles alongside the .node/.dll.
const prebuildDir = join(srcRoot, 'prebuilds', `${platform}-${arch}`)
if (existsSync(prebuildDir)) {
const destPrebuild = join(destRoot, 'prebuilds', `${platform}-${arch}`)
mkdirSync(destPrebuild, { recursive: true })
for (const entry of readdirSync(prebuildDir, { withFileTypes: true })) {
if (entry.name === 'conpty' && entry.isDirectory()) {
cpSync(join(prebuildDir, 'conpty'), join(destPrebuild, 'conpty'), { recursive: true })
continue
}
if (entry.isFile() && /\.(node|dll|exe)$/.test(entry.name)) {
cpSync(join(prebuildDir, entry.name), join(destPrebuild, entry.name))
continue
}
if (entry.name === 'spawn-helper') {
const destFile = join(destPrebuild, entry.name)
cpSync(join(prebuildDir, entry.name), destFile)
makeExecutable(destFile)
}
}
}
// build/Release/* — present when node-pty was compiled locally
// (e.g. no prebuild available for this Electron ABI/platform combo).
// Only stage this when the target matches the host, because
// build/Release contains a binary compiled for the *host's* platform
// and architecture. Staging a host binary for a different target (e.g.
// a macOS Mach-O .node staged for a linux-arm64 target) ships a broken
// app that crashes the first time a terminal is spawned.
if (hostMatch) {
const buildReleaseDir = join(srcRoot, 'build/Release')
copyBuildRelease(buildReleaseDir, join(destRoot, 'build/Release'))
}
// Check whether a native binary for this target was staged.
const stagedDirs = [
join(destRoot, 'prebuilds', `${platform}-${arch}`),
join(destRoot, 'build/Release')
]
const hasNativeBinary = stagedDirs.some((dir) => {
if (!existsSync(dir)) return false
return readdirSync(dir, { recursive: true }).some((name) => String(name).endsWith('.node'))
})
if (!hasNativeBinary) {
if (platform !== process.platform) {
throw new Error(
`[stage-native-deps] no prebuilt binary for ${platform}-${arch} and ` +
`cannot cross-compile native modules from ${process.platform}-${process.arch}. ` +
`Build on the target platform or provide a prebuild.`
)
}
// Same platform, possibly different arch — rebuild from source with
// the target architecture so electron-rebuild produces the correct
// binary rather than defaulting to the host's arch.
console.log(
`[stage-native-deps] no native binary for ${platform}-${arch}; ` +
`running electron-rebuild (target arch: ${arch})...`
)
const rebuildArgs = [
'../../node_modules/.bin/electron-rebuild',
'-f',
'-w',
'node-pty',
'--arch',
arch
]
const result = spawnSync(process.execPath, rebuildArgs, {
cwd: projectRoot,
stdio: 'inherit'
})
if (result.status !== 0) {
throw new Error(
`electron-rebuild failed for ${platform}-${arch} (exit ${result.status}). ` +
`Cannot stage node-pty without a native binary.`
)
}
// Re-copy build/Release after electron-rebuild populated it.
const buildReleaseDir = join(srcRoot, 'build/Release')
copyBuildRelease(buildReleaseDir, join(destRoot, 'build/Release'))
}
// Validate every staged .node binary matches the target platform.
validateStagedBinaries(destRoot, platform)
console.log(`[stage-native-deps] staged node-pty (${platform}-${arch}) -> ${destRoot}`)
return destRoot
}
export function stageNodePty({ platform = process.platform, arch = process.arch } = {}) {
const srcRoot = resolveNodePtyRoot()
const destRoot = resolve(projectRoot, 'dist/node_modules/node-pty')
return stageNodePtyInto(srcRoot, destRoot, { platform, arch })
}
// ─── get-windows (read_window_below tool) ────────────────────────────
//
// Staged like node-pty: external to the esbuild bundle, resolved at runtime
// from dist/node_modules (which asarUnpack ships unpacked via `dist/**`).
//
// The published package's lib/windows.js statically imports
// @mapbox/node-pre-gyp — and index.js statically imports lib/windows.js on
// EVERY platform — so shipping it verbatim would drag node-pre-gyp's whole
// dependency tree into the package. pre-gyp is only used to *locate* the
// prebuilt .node we stage ourselves, so the staged copy replaces
// lib/windows.js with a resolver that requires the staged binding directly
// (and fails soft to no-op stubs, matching upstream's missing-binding
// behavior).
const STAGED_WINDOWS_JS = `// Rewritten by stage-native-deps.mjs: resolves the staged prebuilt binding
// directly instead of through the pre-gyp locator (see stageGetWindowsInto).
import path from 'node:path';
import fs from 'node:fs';
import {fileURLToPath} from 'node:url';
import {createRequire} from 'node:module';
const getAddon = () => {
\tconst require = createRequire(import.meta.url);
\tconst bindingRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), 'binding');
\ttry {
\t\tfor (const dir of fs.readdirSync(bindingRoot)) {
\t\t\tconst bindingPath = path.join(bindingRoot, dir, 'node-get-windows.node');
\t\t\tif (fs.existsSync(bindingPath)) {
\t\t\t\treturn require(bindingPath);
\t\t\t}
\t\t}
\t} catch {}
\treturn {
\t\tgetActiveWindow() {},
\t\tgetOpenWindows() {},
\t};
};
export async function activeWindow() {
\treturn getAddon().getActiveWindow();
}
export function activeWindowSync() {
\treturn getAddon().getActiveWindow();
}
export function openWindows() {
\treturn getAddon().getOpenWindows();
}
export function openWindowsSync() {
\treturn getAddon().getOpenWindows();
}
`
function resolveGetWindowsRoot() {
// get-windows is an optionalDependency (its node-pre-gyp install script has
// no Linux or Windows ARM64 prebuilt and its node-gyp fallback may fail, so
// `npm ci` can skip it entirely on those targets). Return null when it is
// absent; the caller decides whether that is fatal per platform and arch.
try {
// get-windows' exports map doesn't expose ./package.json; resolve the entry
// (index.js sits at the package root) and take its directory.
const entryPath = require.resolve('get-windows', {
paths: [projectRoot]
})
return dirname(entryPath)
} catch {
return null
}
}
/**
* Stage get-windows into `destRoot` for `platform`.
*
* Per-platform native payload: macOS ships the `main` Swift helper binary
* (universal, present in every published tarball), Windows the node-pre-gyp
* prebuilt under lib/binding (downloaded by the package's install script on
* a Windows host — cross-platform packs already can't happen, see
* stageNodePtyInto), Linux nothing (it shells out to xprop at runtime).
*/
const GET_WINDOWS_VERSION = '9.3.0'
export function stageGetWindowsInto(
srcRoot,
destRoot,
{ platform = process.platform, arch = process.arch, install } = {}
) {
// The STAGED_WINDOWS_JS rewrite mirrors this exact version's export surface.
// A version bump must fail the build here until the rewrite is re-verified —
// otherwise it ships stale and fails soft as a generic "unavailable".
const srcVersion = JSON.parse(readFileSync(join(srcRoot, 'package.json'), 'utf8')).version
if (srcVersion !== GET_WINDOWS_VERSION) {
throw new Error(
`[stage-native-deps] get-windows is ${srcVersion} but the staged lib/windows.js ` +
`rewrite was verified against ${GET_WINDOWS_VERSION}. Re-verify the rewrite ` +
`(STAGED_WINDOWS_JS) against the new version, then update GET_WINDOWS_VERSION.`
)
}
rmSync(destRoot, { recursive: true, force: true })
mkdirSync(destRoot, { recursive: true })
cpSync(join(srcRoot, 'package.json'), join(destRoot, 'package.json'))
cpSync(join(srcRoot, 'index.js'), join(destRoot, 'index.js'))
// lib/*.js only — NOT copyGlobByExt, which recurses into lib/binding and
// stages empty dirs for every prebuilt slot (including the darwin one the
// tarball bundles on all platforms). Bindings are staged explicitly below.
mkdirSync(join(destRoot, 'lib'), { recursive: true })
for (const entry of readdirSync(join(srcRoot, 'lib'), { withFileTypes: true })) {
if (entry.isFile() && entry.name.endsWith('.js')) {
cpSync(join(srcRoot, 'lib', entry.name), join(destRoot, 'lib', entry.name))
}
}
writeFileSync(join(destRoot, 'lib', 'windows.js'), STAGED_WINDOWS_JS)
if (platform === 'darwin') {
const helper = join(srcRoot, 'main')
if (!existsSync(helper)) {
throw new Error('[stage-native-deps] get-windows is missing its macOS helper binary (main)')
}
cpSync(helper, join(destRoot, 'main'))
makeExecutable(join(destRoot, 'main'))
}
if (platform === 'win32') {
// The published tarball bundles a darwin binding dir on EVERY platform
// (its `files` includes lib/), so a Windows host's lib/binding holds both
// that and the win32 dir node-pre-gyp downloaded. Stage only dirs naming
// the target platform; the classify gate below still catches a dir that
// claims win32 but holds a foreign binary.
const bindingRoot = join(srcRoot, 'lib', 'binding')
const scanBindingDirs = () =>
existsSync(bindingRoot)
? readdirSync(bindingRoot).filter(
(dir) =>
dir.includes(`-${platform}-`) &&
dir.endsWith(`-${arch}`) &&
existsSync(join(bindingRoot, dir, 'node-get-windows.node'))
)
: []
let bindingDirs = scanBindingDirs()
let installAttempted = false
if (bindingDirs.length === 0 && arch === 'arm64') {
// get-windows 9.3.0 publishes win32 prebuilds for ia32/x64 only.
// The staged windows.js deliberately fails soft when binding/ is absent,
// so preserve the desktop build and disable only window enumeration.
console.warn(
'[stage-native-deps] get-windows has no win32-arm64 prebuilt binding; ' +
'staging the fail-soft JS surface without native window enumeration.'
)
} else if (bindingDirs.length === 0 && typeof install === 'function') {
// A plain `npm install` won't re-run an install script for a package
// that is already on disk, so every checkout that installed while
// get-windows was missing from allowScripts stays bricked even after
// the allowlist is fixed. Invoke node-pre-gyp directly: npm treats this
// optional dependency's failed lifecycle as non-fatal and can report a
// successful rebuild without producing the Windows binding.
console.log(
'[stage-native-deps] get-windows has no win32 binding; running its native installer...'
)
installAttempted = true
install()
bindingDirs = scanBindingDirs()
}
if (bindingDirs.length === 0 && arch !== 'arm64') {
const reason = installAttempted
? `native installer completed without producing a win32-${arch} binding under lib/binding`
: `has no win32-${arch} prebuilt binding under lib/binding`
throw new Error(`[stage-native-deps] get-windows ${reason}`)
}
for (const dir of bindingDirs) {
const dest = join(destRoot, 'lib', 'binding', dir)
mkdirSync(dest, { recursive: true })
const destFile = join(dest, 'node-get-windows.node')
cpSync(join(bindingRoot, dir, 'node-get-windows.node'), destFile)
const classified = classifyNativeBinary(destFile)
if (classified !== platform) {
throw new Error(
`[stage-native-deps] get-windows binding ${dir}/node-get-windows.node: ` +
`expected ${platform}, got ${classified ?? 'unknown'}. ` +
'Refusing to stage a binary compiled for the wrong platform.'
)
}
}
}
console.log(`[stage-native-deps] staged get-windows (${platform}) -> ${destRoot}`)
return destRoot
}
export function installGetWindowsNativeBinding(
srcRoot,
{ resolveInstaller, spawn = spawnSync } = {}
) {
let installerPath
try {
const resolveNodePreGyp =
resolveInstaller ??
(() =>
require.resolve('@mapbox/node-pre-gyp/bin/node-pre-gyp', {
paths: [srcRoot]
}))
installerPath = resolveNodePreGyp()
} catch (error) {
const detail = error instanceof Error ? error.message : String(error)
throw new Error(`[stage-native-deps] cannot resolve get-windows native installer: ${detail}`)
}
const result = spawn(process.execPath, [installerPath, 'install', '--fallback-to-build'], {
cwd: srcRoot,
stdio: 'inherit'
})
if (result.error) {
throw new Error(
`[stage-native-deps] get-windows native installer could not start: ${result.error.message}`
)
}
if (result.status !== 0) {
throw new Error(`[stage-native-deps] get-windows native installer exited with ${result.status}`)
}
}
export function stageGetWindows(
{
platform = process.platform,
arch = process.arch,
resolveRoot = resolveGetWindowsRoot
} = {}
) {
const srcRoot = resolveRoot()
const destRoot = resolve(projectRoot, 'dist/node_modules/get-windows')
if (!srcRoot) {
// npm may omit an optional dependency whose install script fails. That is
// expected on Linux and win32-arm64 because get-windows 9.3.0 publishes no
// native prebuilt for either target. The runtime import already fails soft,
// so disable only window enumeration instead of failing the Desktop build.
// Other Windows architectures and macOS have supported native payloads and
// remain fail-closed so a broken package cannot ship silently.
const canDegrade = platform === 'linux' || (platform === 'win32' && arch === 'arm64')
if (canDegrade) {
console.warn(
`[stage-native-deps] get-windows not installed (optional dep skipped for ${platform}-${arch}); ` +
'read_window_below will be unavailable in this build'
)
return undefined
}
throw new Error(
`[stage-native-deps] get-windows is not installed; cannot stage its ${platform}-${arch} native payload`
)
}
// Only a win32 host can produce the win32 binding, so a cross-platform pack
// has nothing to gain from the native installer.
const install =
platform === 'win32' && process.platform === 'win32'
? () => installGetWindowsNativeBinding(srcRoot)
: undefined
return stageGetWindowsInto(srcRoot, destRoot, { platform, arch, install })
}
// Allow direct CLI invocation: node scripts/stage-native-deps.mjs [platform] [arch]
if (isMain(import.meta.url)) {
const [platform, arch] = process.argv.slice(2)
stageNodePty({ platform, arch })
stageGetWindows({ platform, arch })
}
@@ -0,0 +1,637 @@
import assert from 'node:assert/strict'
import fs, { existsSync } from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { pathToFileURL } from 'node:url'
import { test } from 'vitest'
import {
installGetWindowsNativeBinding,
stageGetWindows,
stageGetWindowsInto,
stageNodePtyInto,
classifyNativeBinary
} from '../scripts/stage-native-deps.mjs'
const { join } = path
// ─── fixtures ──────────────────────────────────────────────────────
//
// Create minimal fake .node files with correct magic bytes so the
// binary classifier and the staging validator exercise real code paths
// without needing actual native modules.
/** Write a fake .node file with the given platform's magic bytes. */
function makeFakeNode(filePath, platform) {
const headers = {
linux: Buffer.from([0x7f, 0x45, 0x4c, 0x46, 0x00, 0x00, 0x00, 0x00]), // ELF
// On x64/arm64 Darwin, Mach-O binaries are stored little-endian on disk
// (MH_CIGAM_64 = cffaedfe). This is the form node-pty's prebuilds ship in.
darwin: Buffer.from([0xcf, 0xfa, 0xed, 0xfe, 0x00, 0x00, 0x00, 0x00]), // Mach-O 64-bit LE (CIGAM_64)
win32: Buffer.from([0x4d, 0x5a, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]), // MZ (PE)
}
fs.mkdirSync(path.dirname(filePath), { recursive: true })
fs.writeFileSync(filePath, headers[platform] ?? headers.linux)
}
/** Create a minimal fake node-pty source tree in a temp dir. */
function makeFakeNodePty(srcRoot, { prebuildPlatform, prebuildArch } = {}) {
fs.mkdirSync(srcRoot, { recursive: true })
fs.writeFileSync(join(srcRoot, 'package.json'), JSON.stringify({ name: 'node-pty', main: 'lib/index.js' }))
fs.mkdirSync(join(srcRoot, 'lib'), { recursive: true })
fs.writeFileSync(join(srcRoot, 'lib', 'index.js'), 'module.exports = {};')
if (prebuildPlatform && prebuildArch) {
const prebuildDir = join(srcRoot, 'prebuilds', `${prebuildPlatform}-${prebuildArch}`)
makeFakeNode(join(prebuildDir, 'pty.node'), prebuildPlatform)
}
}
function makeFakeUnixTerminal(srcRoot) {
fs.writeFileSync(
join(srcRoot, 'lib', 'unixTerminal.js'),
[
"exports.resolveHelper = function (helperPath) {",
" helperPath = helperPath.replace('app.asar', 'app.asar.unpacked');",
" helperPath = helperPath.replace('node_modules.asar', 'node_modules.asar.unpacked');",
' return helperPath;',
'};'
].join('\n')
)
}
// ─── classifyNativeBinary tests ─────────────────────────────────────
test('classifyNativeBinary detects ELF as linux', () => {
const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-'))
try {
const f = join(tmp, 'test.node')
fs.writeFileSync(f, Buffer.from([0x7f, 0x45, 0x4c, 0x46, 0x00, 0x00]))
assert.equal(classifyNativeBinary(f), 'linux')
} finally {
fs.rmSync(tmp, { recursive: true, force: true })
}
})
test('classifyNativeBinary detects Mach-O 64-bit BE as darwin', () => {
const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-'))
try {
const f = join(tmp, 'test.node')
fs.writeFileSync(f, Buffer.from([0xfe, 0xed, 0xfa, 0xcf, 0x00, 0x00]))
assert.equal(classifyNativeBinary(f), 'darwin')
} finally {
fs.rmSync(tmp, { recursive: true, force: true })
}
})
test('classifyNativeBinary detects Mach-O 64-bit LE (CIGAM_64) as darwin', () => {
const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-'))
try {
const f = join(tmp, 'test.node')
fs.writeFileSync(f, Buffer.from([0xcf, 0xfa, 0xed, 0xfe, 0x00, 0x00]))
assert.equal(classifyNativeBinary(f), 'darwin')
} finally {
fs.rmSync(tmp, { recursive: true, force: true })
}
})
test('classifyNativeBinary detects Mach-O 32-bit BE as darwin', () => {
const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-'))
try {
const f = join(tmp, 'test.node')
fs.writeFileSync(f, Buffer.from([0xfe, 0xed, 0xfa, 0xce, 0x00, 0x00]))
assert.equal(classifyNativeBinary(f), 'darwin')
} finally {
fs.rmSync(tmp, { recursive: true, force: true })
}
})
test('classifyNativeBinary detects Mach-O 32-bit LE (CIGAM) as darwin', () => {
const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-'))
try {
const f = join(tmp, 'test.node')
fs.writeFileSync(f, Buffer.from([0xce, 0xfa, 0xed, 0xfe, 0x00, 0x00]))
assert.equal(classifyNativeBinary(f), 'darwin')
} finally {
fs.rmSync(tmp, { recursive: true, force: true })
}
})
test('classifyNativeBinary detects Fat/Universal BE (cafebabe) as darwin', () => {
const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-'))
try {
const f = join(tmp, 'test.node')
fs.writeFileSync(f, Buffer.from([0xca, 0xfe, 0xba, 0xbe, 0x00, 0x00]))
assert.equal(classifyNativeBinary(f), 'darwin')
} finally {
fs.rmSync(tmp, { recursive: true, force: true })
}
})
test('classifyNativeBinary detects Fat/Universal LE (bebafeca / FAT_CIGAM) as darwin', () => {
const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-'))
try {
const f = join(tmp, 'test.node')
fs.writeFileSync(f, Buffer.from([0xbe, 0xba, 0xfe, 0xca, 0x00, 0x00]))
assert.equal(classifyNativeBinary(f), 'darwin')
} finally {
fs.rmSync(tmp, { recursive: true, force: true })
}
})
test('classifyNativeBinary detects PE (MZ) as win32', () => {
const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-'))
try {
const f = join(tmp, 'test.node')
fs.writeFileSync(f, Buffer.from([0x4d, 0x5a, 0x00, 0x00, 0x00, 0x00]))
assert.equal(classifyNativeBinary(f), 'win32')
} finally {
fs.rmSync(tmp, { recursive: true, force: true })
}
})
test('classifyNativeBinary returns null for unrecognized magic', () => {
const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-'))
try {
const f = join(tmp, 'test.node')
fs.writeFileSync(f, Buffer.from([0x00, 0x00, 0x00, 0x00, 0x00, 0x00]))
assert.equal(classifyNativeBinary(f), null)
} finally {
fs.rmSync(tmp, { recursive: true, force: true })
}
})
test('classifyNativeBinary returns null for a missing file', () => {
assert.equal(classifyNativeBinary('/nonexistent/path/to/thing.node'), null)
})
// ─── cross-target regression tests ──────────────────────────────────
//
// The core bug: stageNodePty receives { platform, arch } from
// electron-builder but unconditionally copies host build/Release, staging
// a host binary for a foreign target. These tests prove the fix:
//
// 1. A host build/Release must NOT be staged for a foreign platform.
// 2. A matching prebuild IS staged for a foreign target.
// 3. A foreign target with no prebuild throws (fail closed).
// 4. A host build/Release IS staged for a matching target.
// 5. Validation rejects a binary whose magic bytes don't match the target.
test('cross-target: host build/Release is NOT staged for a foreign platform', () => {
const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-'))
try {
const srcRoot = join(tmp, 'node-pty')
const destRoot = join(tmp, 'dest')
// Create a node-pty tree with ONLY a host build/Release (no prebuild).
makeFakeNodePty(srcRoot)
const buildReleaseDir = join(srcRoot, 'build', 'Release')
makeFakeNode(join(buildReleaseDir, 'pty.node'), process.platform)
// Request a foreign platform (different from the host).
const foreignPlatform = process.platform === 'linux' ? 'darwin' : 'linux'
assert.throws(
() => stageNodePtyInto(srcRoot, destRoot, { platform: foreignPlatform, arch: 'x64' }),
/cannot cross-compile/i
)
// build/Release must NOT have been copied to the dest tree.
assert.equal(
existsSync(join(destRoot, 'build', 'Release', 'pty.node')),
false,
'host build/Release .node must not be staged for a foreign target'
)
} finally {
fs.rmSync(tmp, { recursive: true, force: true })
}
})
test('cross-target: matching prebuild IS staged for a foreign target', () => {
const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-'))
try {
const srcRoot = join(tmp, 'node-pty')
const destRoot = join(tmp, 'dest')
// Host is (say) darwin. Request linux-x64, which has a prebuild.
const foreignPlatform = process.platform === 'linux' ? 'darwin' : 'linux'
makeFakeNodePty(srcRoot, { prebuildPlatform: foreignPlatform, prebuildArch: 'x64' })
// Also create a host build/Release that should NOT be staged.
makeFakeNode(join(srcRoot, 'build', 'Release', 'pty.node'), process.platform)
stageNodePtyInto(srcRoot, destRoot, { platform: foreignPlatform, arch: 'x64' })
// The foreign prebuild must be staged.
const stagedPrebuild = join(destRoot, 'prebuilds', `${foreignPlatform}-x64`, 'pty.node')
assert.equal(existsSync(stagedPrebuild), true, 'foreign prebuild must be staged')
// The host build/Release must NOT be staged.
assert.equal(
existsSync(join(destRoot, 'build', 'Release', 'pty.node')),
false,
'host build/Release must not be staged for a foreign target'
)
} finally {
fs.rmSync(tmp, { recursive: true, force: true })
}
})
test('cross-target: foreign target with no prebuild throws (fail closed)', () => {
const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-'))
try {
const srcRoot = join(tmp, 'node-pty')
const destRoot = join(tmp, 'dest')
// Create a tree with a host build/Release but no foreign prebuild.
makeFakeNodePty(srcRoot)
makeFakeNode(join(srcRoot, 'build', 'Release', 'pty.node'), process.platform)
const foreignPlatform = process.platform === 'linux' ? 'darwin' : 'linux'
assert.throws(
() => stageNodePtyInto(srcRoot, destRoot, { platform: foreignPlatform, arch: 'x64' }),
/cannot cross-compile/i
)
} finally {
fs.rmSync(tmp, { recursive: true, force: true })
}
})
test('host-target: host build/Release IS staged for a matching target', () => {
const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-'))
try {
const srcRoot = join(tmp, 'node-pty')
const destRoot = join(tmp, 'dest')
makeFakeNodePty(srcRoot)
makeFakeNode(join(srcRoot, 'build', 'Release', 'pty.node'), process.platform)
stageNodePtyInto(srcRoot, destRoot, { platform: process.platform, arch: process.arch })
assert.equal(
existsSync(join(destRoot, 'build', 'Release', 'pty.node')),
true,
'host build/Release must be staged for a matching target'
)
} finally {
fs.rmSync(tmp, { recursive: true, force: true })
}
})
test.skipIf(process.platform === 'win32')(
'host-target: staged node-pty resolves an already-unpacked helper and preserves executable helpers',
async () => {
const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-'))
try {
const srcRoot = join(tmp, 'node-pty')
const destRoot = join(tmp, 'dest')
const prebuildDir = join(srcRoot, 'prebuilds', `${process.platform}-${process.arch}`)
const buildReleaseDir = join(srcRoot, 'build', 'Release')
makeFakeNodePty(srcRoot, {
prebuildPlatform: process.platform,
prebuildArch: process.arch
})
makeFakeUnixTerminal(srcRoot)
makeFakeNode(join(buildReleaseDir, 'pty.node'), process.platform)
fs.writeFileSync(join(prebuildDir, 'spawn-helper'), 'prebuild helper')
fs.writeFileSync(join(buildReleaseDir, 'spawn-helper'), 'build helper')
fs.chmodSync(join(prebuildDir, 'spawn-helper'), 0o644)
fs.chmodSync(join(buildReleaseDir, 'spawn-helper'), 0o644)
stageNodePtyInto(srcRoot, destRoot, { platform: process.platform, arch: process.arch })
const stagedUnixTerminalUrl = pathToFileURL(join(destRoot, 'lib', 'unixTerminal.js'))
stagedUnixTerminalUrl.searchParams.set('t', String(Date.now()))
const stagedUnixTerminal = await import(stagedUnixTerminalUrl.href)
const unpackedHelper = join(
tmp,
'Hermes.app',
'Contents',
'Resources',
'app.asar.unpacked',
'dist',
'node_modules',
'node-pty',
'prebuilds',
`${process.platform}-${process.arch}`,
'spawn-helper'
)
const nodeModulesUnpackedHelper = unpackedHelper.replace(
`${path.sep}node_modules${path.sep}`,
`${path.sep}node_modules.asar.unpacked${path.sep}`
)
assert.equal(stagedUnixTerminal.resolveHelper(unpackedHelper), unpackedHelper)
assert.equal(
stagedUnixTerminal.resolveHelper(nodeModulesUnpackedHelper),
nodeModulesUnpackedHelper
)
assert.equal(
fs.statSync(join(destRoot, 'prebuilds', `${process.platform}-${process.arch}`, 'spawn-helper')).mode & 0o777,
0o755
)
assert.equal(fs.statSync(join(destRoot, 'build', 'Release', 'spawn-helper')).mode & 0o777, 0o755)
} finally {
fs.rmSync(tmp, { recursive: true, force: true })
}
}
)
test('validation rejects a staged binary with the wrong platform magic', () => {
const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-'))
try {
const srcRoot = join(tmp, 'node-pty')
const destRoot = join(tmp, 'dest')
// Create a prebuild dir that claims to be linux-x64 but contains
// a darwin (Mach-O) binary. This simulates the original bug where
// a host binary ends up in a foreign target's prebuild slot.
makeFakeNodePty(srcRoot, { prebuildPlatform: 'linux', prebuildArch: 'x64' })
// Overwrite the prebuild .node with the WRONG platform magic.
makeFakeNode(join(srcRoot, 'prebuilds', 'linux-x64', 'pty.node'), 'darwin')
assert.throws(
() => stageNodePtyInto(srcRoot, destRoot, { platform: 'linux', arch: 'x64' }),
/platform mismatch/i
)
} finally {
fs.rmSync(tmp, { recursive: true, force: true })
}
})
// ─── stageGetWindowsInto tests ──────────────────────────────────────
/** Create a minimal fake get-windows source tree in a temp dir. */
function makeFakeGetWindows(srcRoot, { version = '9.3.0', bindings = [] } = {}) {
fs.mkdirSync(join(srcRoot, 'lib'), { recursive: true })
fs.writeFileSync(join(srcRoot, 'package.json'), JSON.stringify({ name: 'get-windows', version, main: 'index.js' }))
fs.writeFileSync(join(srcRoot, 'index.js'), 'export {};')
fs.writeFileSync(join(srcRoot, 'lib', 'windows.js'), '// upstream pre-gyp loader')
fs.writeFileSync(join(srcRoot, 'main'), '#!/bin/sh\n')
for (const { dir, platform } of bindings) {
makeFakeNode(join(srcRoot, 'lib', 'binding', dir, 'node-get-windows.node'), platform)
}
}
test('win32 staging skips the darwin binding the tarball bundles on every platform', () => {
const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-'))
try {
const srcRoot = join(tmp, 'get-windows')
const destRoot = join(tmp, 'dest')
// The shape every real Windows build host has: the darwin binding
// committed into the published tarball PLUS the win32 binding
// node-pre-gyp downloaded at install time.
makeFakeGetWindows(srcRoot, {
bindings: [
{ dir: 'napi-9-darwin-unknown-arm64', platform: 'darwin' },
{ dir: 'napi-9-win32-unknown-x64', platform: 'win32' }
]
})
stageGetWindowsInto(srcRoot, destRoot, { platform: 'win32', arch: 'x64' })
assert.ok(existsSync(join(destRoot, 'lib', 'binding', 'napi-9-win32-unknown-x64', 'node-get-windows.node')))
assert.ok(!existsSync(join(destRoot, 'lib', 'binding', 'napi-9-darwin-unknown-arm64')))
} finally {
fs.rmSync(tmp, { recursive: true, force: true })
}
})
test('win32 staging rejects a binding dir that claims win32 but holds a foreign binary', () => {
const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-'))
try {
const srcRoot = join(tmp, 'get-windows')
const destRoot = join(tmp, 'dest')
makeFakeGetWindows(srcRoot, {
bindings: [{ dir: 'napi-9-win32-unknown-x64', platform: 'darwin' }]
})
assert.throws(
() => stageGetWindowsInto(srcRoot, destRoot, { platform: 'win32', arch: 'x64' }),
/expected win32, got darwin/
)
} finally {
fs.rmSync(tmp, { recursive: true, force: true })
}
})
test('win32-x64 staging fails when only foreign bindings exist', () => {
const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-'))
try {
const srcRoot = join(tmp, 'get-windows')
const destRoot = join(tmp, 'dest')
makeFakeGetWindows(srcRoot, {
bindings: [{ dir: 'napi-9-darwin-unknown-arm64', platform: 'darwin' }]
})
assert.throws(
() => stageGetWindowsInto(srcRoot, destRoot, { platform: 'win32', arch: 'x64' }),
/no win32-x64 prebuilt binding/
)
} finally {
fs.rmSync(tmp, { recursive: true, force: true })
}
})
test('win32-arm64 staging omits incompatible bindings and keeps the fail-soft JS surface', () => {
const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-'))
try {
const srcRoot = join(tmp, 'get-windows')
const destRoot = join(tmp, 'dest')
makeFakeGetWindows(srcRoot, {
bindings: [
{ dir: 'napi-9-darwin-unknown-arm64', platform: 'darwin' },
{ dir: 'napi-9-win32-unknown-x64', platform: 'win32' }
]
})
stageGetWindowsInto(srcRoot, destRoot, { platform: 'win32', arch: 'arm64' })
assert.ok(existsSync(join(destRoot, 'lib', 'windows.js')))
assert.ok(!existsSync(join(destRoot, 'lib', 'binding')))
} finally {
fs.rmSync(tmp, { recursive: true, force: true })
}
})
test('win32 staging self-heals through the native installer when the binding is missing', () => {
const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-'))
try {
const srcRoot = join(tmp, 'get-windows')
const destRoot = join(tmp, 'dest')
// The bricked state a blocked install script leaves behind: the package is
// present, lib/binding was never populated by node-pre-gyp.
makeFakeGetWindows(srcRoot, { bindings: [] })
let calls = 0
const install = () => {
calls += 1
makeFakeNode(
join(srcRoot, 'lib', 'binding', 'napi-9-win32-unknown-x64', 'node-get-windows.node'),
'win32'
)
}
stageGetWindowsInto(srcRoot, destRoot, { platform: 'win32', arch: 'x64', install })
assert.equal(calls, 1)
assert.ok(
existsSync(join(destRoot, 'lib', 'binding', 'napi-9-win32-unknown-x64', 'node-get-windows.node'))
)
} finally {
fs.rmSync(tmp, { recursive: true, force: true })
}
})
test('win32 staging rejects a successful installer that produces no binding', () => {
const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-'))
try {
const srcRoot = join(tmp, 'get-windows')
const destRoot = join(tmp, 'dest')
makeFakeGetWindows(srcRoot, { bindings: [] })
assert.throws(
() =>
stageGetWindowsInto(srcRoot, destRoot, {
platform: 'win32',
arch: 'x64',
install: () => {}
}),
(error) => {
assert.match(error.message, /installer completed without producing a win32-x64 binding/)
assert.doesNotMatch(error.message, /npm rebuild/)
return true
}
)
} finally {
fs.rmSync(tmp, { recursive: true, force: true })
}
})
test('get-windows native install invokes node-pre-gyp directly from the package root', () => {
const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-'))
try {
const srcRoot = join(tmp, 'get-windows')
const installer = join(
srcRoot,
'node_modules',
'@mapbox',
'node-pre-gyp',
'bin',
'node-pre-gyp'
)
fs.mkdirSync(path.dirname(installer), { recursive: true })
fs.writeFileSync(
join(srcRoot, 'node_modules', '@mapbox', 'node-pre-gyp', 'package.json'),
JSON.stringify({ name: '@mapbox/node-pre-gyp', version: '1.0.11' })
)
fs.writeFileSync(installer, '')
const calls = []
installGetWindowsNativeBinding(srcRoot, {
spawn: (command, args, options) => {
calls.push({ command, args, options })
return { status: 0 }
}
})
assert.deepEqual(calls, [
{
command: process.execPath,
args: [fs.realpathSync(installer), 'install', '--fallback-to-build'],
options: { cwd: srcRoot, stdio: 'inherit' }
}
])
} finally {
fs.rmSync(tmp, { recursive: true, force: true })
}
})
test('get-windows native install surfaces node-pre-gyp failure', () => {
assert.throws(
() =>
installGetWindowsNativeBinding('C:\\fake\\get-windows', {
resolveInstaller: () => 'C:\\fake\\node-pre-gyp',
spawn: () => ({ status: 1 })
}),
/native installer exited with 1/
)
})
test('staging refuses a get-windows version the lib/windows.js rewrite was not verified against', () => {
const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-'))
try {
const srcRoot = join(tmp, 'get-windows')
const destRoot = join(tmp, 'dest')
makeFakeGetWindows(srcRoot, { version: '9.4.0' })
assert.throws(
() => stageGetWindowsInto(srcRoot, destRoot, { platform: 'darwin' }),
/verified against 9\.3\.0/
)
} finally {
fs.rmSync(tmp, { recursive: true, force: true })
}
})
test('darwin staging ships the Swift helper executable and the rewritten windows.js', () => {
const tmp = fs.mkdtempSync(join(os.tmpdir(), 'hermes-stage-'))
try {
const srcRoot = join(tmp, 'get-windows')
const destRoot = join(tmp, 'dest')
makeFakeGetWindows(srcRoot)
stageGetWindowsInto(srcRoot, destRoot, { platform: 'darwin' })
assert.equal(fs.statSync(join(destRoot, 'main')).mode & 0o777, 0o755)
const staged = fs.readFileSync(join(destRoot, 'lib', 'windows.js'), 'utf8')
assert.match(staged, /Rewritten by stage-native-deps\.mjs/)
assert.ok(!staged.includes('node-pre-gyp'), 'pre-gyp loader must not survive staging')
} finally {
fs.rmSync(tmp, { recursive: true, force: true })
}
})
// ─── stageGetWindows (optionalDependency gate) ──────────────────────
//
// get-windows is an optionalDependency: on Linux its node-pre-gyp install
// script fails because no prebuilt exists. Windows ARM64 has the same package
// state: its prebuilt URL returns 404 and npm may omit the optional dependency.
// Staging skips those unsupported targets, but supported native targets remain
// a hard failure when the package is missing.
test('linux staging skips when get-windows is absent (optional dep skipped by npm)', () => {
assert.equal(stageGetWindows({ platform: 'linux', resolveRoot: () => null }), undefined)
})
test('darwin staging fails when get-windows is absent', () => {
assert.throws(
() => stageGetWindows({ platform: 'darwin', arch: 'arm64', resolveRoot: () => null }),
/get-windows is not installed/
)
})
test('win32-arm64 staging skips when get-windows is absent after its optional install fails', () => {
assert.equal(
stageGetWindows({ platform: 'win32', arch: 'arm64', resolveRoot: () => null }),
undefined
)
})
test('win32-x64 staging fails when get-windows is absent', () => {
assert.throws(
() => stageGetWindows({ platform: 'win32', arch: 'x64', resolveRoot: () => null }),
/get-windows is not installed/
)
})
+442
View File
@@ -0,0 +1,442 @@
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { spawn, spawnSync } from 'node:child_process'
import { fileURLToPath } from 'node:url'
import { listPackage } from '@electron/asar'
import PACKAGE_JSON from '../package.json' with { type: 'json' }
const MODE = process.argv[2] || 'help'
const ARCH = process.arch === 'arm64' ? 'arm64' : 'x64'
const DESKTOP_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
const RELEASE_ROOT = path.join(DESKTOP_ROOT, 'release')
const PLATFORM = process.platform
// Platform-specific packaged-app layout. The thin installer ships an Electron
// app shell plus extraResources (install-stamp.json + native-deps/) -- it
// no longer bundles the Hermes Agent Python payload (that's fetched at first
// launch via install.ps1 / install.sh, per the Phase 1 thin-installer flow).
const APP = (() => {
if (PLATFORM === 'darwin') {
const appPath = path.join(RELEASE_ROOT, `mac-${ARCH}`, 'Hermes.app')
return {
appPath,
binary: path.join(appPath, 'Contents', 'MacOS', 'Hermes'),
resourcesPath: path.join(appPath, 'Contents', 'Resources'),
asarPath: path.join(appPath, 'Contents', 'Resources', 'app.asar'),
unpackedDistIndex: path.join(appPath, 'Contents', 'Resources', 'app.asar.unpacked', 'dist', 'index.html')
}
}
if (PLATFORM === 'win32') {
const unpacked = path.join(RELEASE_ROOT, 'win-unpacked')
return {
appPath: unpacked,
binary: path.join(unpacked, 'Hermes.exe'),
resourcesPath: path.join(unpacked, 'resources'),
asarPath: path.join(unpacked, 'resources', 'app.asar'),
unpackedDistIndex: path.join(unpacked, 'resources', 'app.asar.unpacked', 'dist', 'index.html')
}
}
// linux unpacked layout matches windows but with different binary name
const unpacked = path.join(RELEASE_ROOT, 'linux-unpacked')
return {
appPath: unpacked,
binary: path.join(unpacked, 'Hermes'),
resourcesPath: path.join(unpacked, 'resources'),
asarPath: path.join(unpacked, 'resources', 'app.asar'),
unpackedDistIndex: path.join(unpacked, 'resources', 'app.asar.unpacked', 'dist', 'index.html')
}
})()
// Default HERMES_HOME for non-sandboxed runs -- matches main.ts's
// resolveHermesHome(). On Windows it's %LOCALAPPDATA%\hermes; elsewhere
// it's ~/.hermes. The fresh-install sandbox launchFresh() sets its own
// HERMES_HOME and never touches this.
const DEFAULT_HERMES_HOME = (() => {
if (PLATFORM === 'win32' && process.env.LOCALAPPDATA) {
return path.join(process.env.LOCALAPPDATA, 'hermes')
}
return path.join(os.homedir(), '.hermes')
})()
const VENV_ROOT = path.join(DEFAULT_HERMES_HOME, 'hermes-agent', 'venv')
const FRESH_SANDBOX_ROOT = path.join(os.tmpdir(), 'hermes-desktop-fresh-install')
function die(message) {
console.error(`\n${message}`)
process.exit(1)
}
function run(command, args, options = {}) {
const result = spawnSync(command, args, {
cwd: options.cwd || DESKTOP_ROOT,
env: options.env || process.env,
shell: Boolean(options.shell) || PLATFORM === 'win32',
stdio: 'inherit'
})
if (result.status !== 0) {
die(`${command} ${args.join(' ')} failed`)
}
}
function exists(target) {
return fs.existsSync(target)
}
// Match node-pty native binding location to what the bundled electron-main.cjs
// resolves at runtime. stage-native-deps.mjs stages node-pty into
// dist/node_modules/node-pty, and dist/** is asarUnpacked (see package.json
// build.asarUnpack), so in a packaged build it lands under
// resources/app.asar.unpacked/dist/node_modules/node-pty — reachable by a bare
// require('node-pty') from the bundle. Upstream node-pty 1.x is N-API based and
// ships per-arch prebuilts under prebuilds/<platform>-<arch>/; nix/local builds
// instead compile from source into build/Release/. The stage script copies
// whichever is present, so we accept either as the native payload.
function expectedNativeDepPaths() {
const root = path.join(APP.resourcesPath, 'app.asar.unpacked', 'dist', 'node_modules', 'node-pty')
const prebuildsDir = path.join(root, 'prebuilds', `${PLATFORM}-${ARCH}`)
const buildReleaseDir = path.join(root, 'build', 'Release')
return {
packageJson: path.join(root, 'package.json'),
prebuildsDir,
buildReleaseDir,
libIndex: path.join(root, 'lib', 'index.js')
}
}
function ensurePlatformBuilds() {
if (PLATFORM === 'darwin') return
if (PLATFORM === 'win32') return
if (PLATFORM === 'linux') return
die(
`Desktop bundle validation is only wired for darwin / win32 / linux; platform=${PLATFORM} is not supported.`
)
}
function ensurePackagedApp() {
if (process.env.HERMES_DESKTOP_SKIP_BUILD === '1' && exists(APP.binary)) {
return
}
run('npm', ['run', 'pack'])
}
function resolveDmgPath() {
if (!exists(RELEASE_ROOT)) {
return path.join(RELEASE_ROOT, `Hermes-${PACKAGE_JSON.version}-${ARCH}.dmg`)
}
const prefix = `Hermes-${PACKAGE_JSON.version}`
const candidates = fs
.readdirSync(RELEASE_ROOT)
.filter(name => name.endsWith('.dmg'))
.filter(name => name.startsWith(prefix))
.filter(name => name.includes(ARCH))
.sort((a, b) => {
const aMtime = fs.statSync(path.join(RELEASE_ROOT, a)).mtimeMs
const bMtime = fs.statSync(path.join(RELEASE_ROOT, b)).mtimeMs
return bMtime - aMtime
})
return candidates.length > 0
? path.join(RELEASE_ROOT, candidates[0])
: path.join(RELEASE_ROOT, `Hermes-${PACKAGE_JSON.version}-${ARCH}.dmg`)
}
function resolveNsisPath() {
// electron-builder NSIS artifactName template is 'Hermes-${version}-${os}-${arch}.${ext}'
if (!exists(RELEASE_ROOT)) return null
const candidates = fs
.readdirSync(RELEASE_ROOT)
.filter(name => /\.exe$/i.test(name) && /win/i.test(name))
.sort((a, b) => {
const aMtime = fs.statSync(path.join(RELEASE_ROOT, a)).mtimeMs
const bMtime = fs.statSync(path.join(RELEASE_ROOT, b)).mtimeMs
return bMtime - aMtime
})
return candidates.length > 0 ? path.join(RELEASE_ROOT, candidates[0]) : null
}
function ensureDmg() {
if (PLATFORM !== 'darwin') {
die('DMG mode is macOS-only; on Windows use the `nsis` mode instead.')
}
if (process.env.HERMES_DESKTOP_SKIP_BUILD === '1' && exists(resolveDmgPath())) {
return
}
run('npm', ['run', 'dist:mac:dmg'])
}
function ensureNsis() {
if (PLATFORM !== 'win32') {
die('NSIS mode is win32-only; on macOS use the `dmg` mode instead.')
}
if (process.env.HERMES_DESKTOP_SKIP_BUILD === '1' && resolveNsisPath()) {
return
}
run('npm', ['run', 'dist:win:nsis'])
}
function openApp() {
if (!exists(APP.binary)) {
die(`Missing packaged app: ${APP.binary}`)
}
if (PLATFORM === 'darwin') {
run('open', ['-n', APP.appPath])
} else if (PLATFORM === 'win32') {
// Spawn detached so the test script exits while the app keeps running.
spawn(APP.binary, [], { detached: true, stdio: 'ignore' }).unref()
} else {
spawn(APP.binary, [], { detached: true, stdio: 'ignore' }).unref()
}
}
function openDmg() {
if (PLATFORM !== 'darwin') {
die('DMG mode is macOS-only.')
}
const dmgPath = resolveDmgPath()
if (!exists(dmgPath)) {
die(`Missing DMG: ${dmgPath}`)
}
run('open', [dmgPath])
}
const CREDENTIAL_ENV_SUFFIXES = [
'_API_KEY',
'_TOKEN',
'_SECRET',
'_PASSWORD',
'_CREDENTIALS',
'_ACCESS_KEY',
'_PRIVATE_KEY',
'_OAUTH_TOKEN'
]
const CREDENTIAL_ENV_NAMES = new Set([
'ANTHROPIC_BASE_URL',
'ANTHROPIC_TOKEN',
'AWS_ACCESS_KEY_ID',
'AWS_SECRET_ACCESS_KEY',
'AWS_SESSION_TOKEN',
'CUSTOM_API_KEY',
'GEMINI_BASE_URL',
'OPENAI_BASE_URL',
'OPENROUTER_BASE_URL',
'OLLAMA_BASE_URL',
'GROQ_BASE_URL',
'XAI_BASE_URL'
])
function isCredentialEnvVar(name) {
if (CREDENTIAL_ENV_NAMES.has(name)) return true
return CREDENTIAL_ENV_SUFFIXES.some(suffix => name.endsWith(suffix))
}
function launchFresh() {
if (!exists(APP.binary)) {
die(`Missing app executable: ${APP.binary}`)
}
const sandbox = fs.mkdtempSync(`${FRESH_SANDBOX_ROOT}-`)
const userDataDir = path.join(sandbox, 'electron-user-data')
const hermesHome = path.join(sandbox, 'hermes-home')
const cwd = path.join(sandbox, 'workspace')
fs.mkdirSync(userDataDir, { recursive: true })
fs.mkdirSync(hermesHome, { recursive: true })
fs.mkdirSync(cwd, { recursive: true })
// Strip every credential-shaped env var so the sandbox is actually fresh.
const env = {}
for (const [key, value] of Object.entries(process.env)) {
if (isCredentialEnvVar(key)) continue
env[key] = value
}
env.HERMES_DESKTOP_CWD = cwd
env.HERMES_DESKTOP_IGNORE_EXISTING = '1'
env.HERMES_DESKTOP_TEST_MODE = 'fresh-install'
env.HERMES_DESKTOP_USER_DATA_DIR = userDataDir
env.HERMES_HOME = hermesHome
delete env.HERMES_DESKTOP_HERMES
delete env.HERMES_DESKTOP_HERMES_ROOT
const child = spawn(APP.binary, [], {
cwd: os.homedir(),
detached: true,
env,
stdio: 'ignore'
})
child.unref()
console.log('\nFresh install sandbox:')
console.log(` root: ${sandbox}`)
console.log(` electron userData: ${userDataDir}`)
console.log(` HERMES_HOME: ${hermesHome}`)
console.log(` cwd: ${cwd}`)
return { runtimeRoot: path.join(hermesHome, 'hermes-agent', 'venv') }
}
// Validate the packaged bundle matches the thin-installer architecture:
// - The Hermes Agent Python payload is NOT shipped (it's fetched at first
// launch via install.ps1's stage protocol).
// - install-stamp.json IS shipped in resources/ with a valid commit + branch.
// - node-pty IS shipped inside app.asar.unpacked/dist/node_modules/node-pty
// with package.json + lib/ + at least one .node binary (the renderer's
// integrated terminal needs this; see Phase 1F.6).
// - The renderer's dist/index.html is reachable (either unpacked or
// inside app.asar).
function validateBundle() {
if (!exists(APP.binary)) {
die(`Missing packaged app binary: ${APP.binary}`)
}
// Negative assertion: the OLD fat-installer factory payload must NOT be
// present anymore. If a stray ship of hermes_cli sneaks back in we want
// to fail loudly rather than re-introduce the 400MB delta we just removed.
const staleFactoryMarker = path.join(APP.resourcesPath, 'hermes-agent', 'hermes_cli', 'main.py')
if (exists(staleFactoryMarker)) {
die(
`Thin-installer regression: factory-payload file should NOT be in the package: ${staleFactoryMarker}`
)
}
// Positive assertion: install-stamp.json carries a sane commit + branch
const stampPath = path.join(APP.resourcesPath, 'install-stamp.json')
if (!exists(stampPath)) {
die(`Missing install-stamp.json (required for first-launch bootstrap pinning): ${stampPath}`)
}
let stamp
try {
stamp = JSON.parse(fs.readFileSync(stampPath, 'utf8'))
} catch (err) {
die(`install-stamp.json is not valid JSON: ${err.message}`)
}
if (!stamp.commit || typeof stamp.commit !== 'string' || stamp.commit.length < 7) {
die(`install-stamp.json is missing a usable commit field: ${JSON.stringify(stamp)}`)
}
if (!stamp.branch || typeof stamp.branch !== 'string') {
die(`install-stamp.json is missing the branch field: ${JSON.stringify(stamp)}`)
}
// Positive assertion: node-pty native deps shipped
const native = expectedNativeDepPaths()
if (!exists(native.packageJson)) {
die(`Missing node-pty package.json in app.asar.unpacked: ${native.packageJson}`)
}
if (!exists(native.libIndex)) {
die(`Missing node-pty lib/index.js in app.asar.unpacked: ${native.libIndex}`)
}
// The native binary lands in prebuilds/<platform>-<arch>/ (downloaded prebuild)
// OR build/Release/ (compiled from source). stage-native-deps.mjs copies
// whichever is present, so accept either.
const nativeBinaryDirs = [native.prebuildsDir, native.buildReleaseDir].filter(exists)
if (nativeBinaryDirs.length === 0) {
die(
`Missing node-pty native binary dir for ${PLATFORM}-${ARCH}: neither ` +
`${native.prebuildsDir} nor ${native.buildReleaseDir} exists`
)
}
const nodeBinaries = nativeBinaryDirs.flatMap(dir =>
fs.readdirSync(dir).filter(name => name.endsWith('.node'))
)
if (nodeBinaries.length === 0) {
die(`No .node native binaries found in: ${nativeBinaryDirs.join(', ')}`)
}
// Darwin requires a runtime-execed spawn-helper alongside pty.node; missing
// it manifests as "ENOENT: spawn-helper" on first pty.spawn() call.
if (PLATFORM === 'darwin') {
const spawnHelper = nativeBinaryDirs
.map(dir => path.join(dir, 'spawn-helper'))
.find(exists)
if (!spawnHelper) {
die(`Missing node-pty spawn-helper (required on darwin) in: ${nativeBinaryDirs.join(', ')}`)
}
}
// Renderer payload check (either unpacked or in the asar)
if (exists(APP.unpackedDistIndex)) {
return { stamp, nodeBinaries }
}
if (!exists(APP.asarPath)) {
die(`Missing renderer payload: neither ${APP.unpackedDistIndex} nor ${APP.asarPath} exists`)
}
const files = listPackage(APP.asarPath)
// Normalize separators because @electron/asar's listPackage returns
// backslash-prefixed entries on Windows ('\\dist\\index.html') and
// forward-slash on Unix.
const normalized = files.map(f => f.replace(/\\/g, '/').replace(/^\/+/, ''))
if (!normalized.includes('dist/index.html')) {
die(`Missing renderer payload file in app.asar: ${APP.asarPath} (expected dist/index.html)`)
}
return { stamp, nodeBinaries }
}
function printArtifacts(options = {}) {
const runtimeRoot = options.runtimeRoot || VENV_ROOT
const stamp = options.stamp
console.log('\nDesktop artifacts:')
console.log(` app: ${APP.appPath}`)
if (PLATFORM === 'darwin') {
console.log(` dmg: ${resolveDmgPath()}`)
} else if (PLATFORM === 'win32') {
const exe = resolveNsisPath()
if (exe) console.log(` installer: ${exe}`)
}
console.log(` runtime: ${runtimeRoot}`)
if (stamp) {
console.log(` install-stamp: ${stamp.commit.slice(0, 12)} on ${stamp.branch}`)
}
if (options.nodeBinaries && options.nodeBinaries.length > 0) {
console.log(` node-pty binaries: ${options.nodeBinaries.join(', ')}`)
}
}
function help() {
console.log(`Usage:
npm run test:desktop:existing # build packaged app, launch with normal PATH/existing Hermes
npm run test:desktop:fresh # build packaged app, launch with temp userData + HERMES_HOME
npm run test:desktop:dmg # (macOS only) build DMG and open it
npm run test:desktop:nsis # (win32 only) build NSIS installer
npm run test:desktop:all # build installer, validate app payload, print paths
Fast rerun (skip rebuild if the packaged app already exists):
HERMES_DESKTOP_SKIP_BUILD=1 npm run test:desktop:fresh
`)
}
ensurePlatformBuilds()
if (MODE === 'existing') {
ensurePackagedApp()
const result = validateBundle()
openApp()
printArtifacts(result)
} else if (MODE === 'fresh') {
ensurePackagedApp()
const result = validateBundle()
printArtifacts({ ...launchFresh(), ...result })
} else if (MODE === 'dmg') {
ensureDmg()
openDmg()
printArtifacts()
} else if (MODE === 'nsis') {
ensureNsis()
printArtifacts(validateBundle())
} else if (MODE === 'all') {
if (PLATFORM === 'darwin') {
ensureDmg()
} else if (PLATFORM === 'win32') {
ensureNsis()
} else {
ensurePackagedApp()
}
printArtifacts(validateBundle())
} else {
help()
}
+8
View File
@@ -0,0 +1,8 @@
import { pathToFileURL } from 'node:url';
// returns true if the passsed file is being invoked from node,
// not imported.
export function isMain(importMetaUrl) {
return importMetaUrl === pathToFileURL(process.argv[1]).href;
}
+176
View File
@@ -0,0 +1,176 @@
/**
* Writes apps/desktop/build/install-stamp.json with the git ref the desktop
* .exe should pin to at first-launch bootstrap time. This file ships inside
* the packaged app via electron-builder's extraResources entry and is read
* by electron/main.ts to drive the install.ps1 stage bootstrap flow.
*
* Schema (subject to bump via STAMP_SCHEMA_VERSION):
* {
* "schemaVersion": 1,
* "commit": "<40-char SHA>",
* "branch": "<branch name>",
* "builtAt": "<ISO 8601 UTC timestamp>",
* "dirty": true|false,
* "source": "ci" | "local" | "fallback"
* }
*
* Source preference order:
* 1. CI env vars ($GITHUB_SHA / $GITHUB_REF_NAME) -- avoid edge cases with
* shallow clones, detached HEADs, etc. in CI.
* 2. Local `git rev-parse` against the parent repo (../..).
* 3. Fallback stamp for local/personal builds from non-git source trees
* (ZIP extract, interrupted clone with no HEAD, etc.).
*
* Dev / out-of-repo builds without git produce an explicit fallback stamp
* rather than aborting the whole build. Bootstrap treats the all-zero
* commit as unpinned and follows the branch instead of fetching a fake SHA.
*/
import { mkdirSync, writeFileSync } from "fs"
import { resolve, join, relative } from "path"
import { execSync } from "child_process"
import { isMain } from "./utils.mjs"
const STAMP_SCHEMA_VERSION = 1
/** All-zero placeholder used when no real commit can be resolved. */
export const FALLBACK_COMMIT = "0000000000000000000000000000000000000000"
export const FALLBACK_BRANCH = "aiturk/main"
const DESKTOP_ROOT = resolve(import.meta.dirname, "..")
const REPO_ROOT = resolve(DESKTOP_ROOT, "..", "..")
const OUT_DIR = join(DESKTOP_ROOT, "build")
const OUT_FILE = join(OUT_DIR, "install-stamp.json")
function tryExec(cmd, opts) {
try {
return execSync(cmd, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], ...opts }).trim()
} catch {
return null
}
}
export function fromCI(env = process.env) {
const sha = env.GITHUB_SHA
if (!sha) return null
const branch = env.GITHUB_REF_NAME || env.GITHUB_HEAD_REF || null
return {
commit: sha,
branch: branch,
dirty: false, // CI builds from a checkout-of-ref by definition
source: "ci"
}
}
export function fromLocalGit(repoRoot = REPO_ROOT, execFn = tryExec) {
const sha = execFn("git rev-parse HEAD", { cwd: repoRoot })
if (!sha) return null
const branch = execFn("git rev-parse --abbrev-ref HEAD", { cwd: repoRoot })
// `git status --porcelain -uno` is empty iff tracked files match HEAD.
// We exclude untracked files (-uno) intentionally: a developer who's
// checked out an installer scratch dir alongside the repo shouldn't
// poison every local build with a [DIRTY] stamp. We DO care about
// tracked-but-modified files because those mean the .exe content
// differs from the commit being pinned.
const status = execFn("git status --porcelain -uno", { cwd: repoRoot })
const dirty = status !== null && status.length > 0
return {
commit: sha,
branch: branch === "HEAD" ? null : branch, // detached HEAD -> null
dirty: dirty,
source: "local"
}
}
export function fromFallback(branch = FALLBACK_BRANCH) {
// Non-git builds (ZIP download, bootstrap installer without a resolvable
// HEAD) cannot determine a real commit. Use a placeholder so local /
// personal builds can still complete. The desktop bootstrap treats the
// all-zero commit as "unknown" and falls back to an unpinned branch
// bootstrap instead of trying to fetch a non-existent GitHub commit.
return {
commit: FALLBACK_COMMIT,
branch: branch || FALLBACK_BRANCH,
dirty: false,
source: "fallback"
}
}
/**
* Resolve the install stamp without writing it. Pure enough for unit tests:
* inject env / execFn / repoRoot to simulate CI, local git, or no-git trees.
*/
export function resolveStamp({
env = process.env,
repoRoot = REPO_ROOT,
execFn = tryExec,
fallbackBranch = FALLBACK_BRANCH
} = {}) {
return fromCI(env) || fromLocalGit(repoRoot, execFn) || fromFallback(fallbackBranch)
}
export function isFallbackCommit(commit) {
return typeof commit === "string" && /^0{7,40}$/.test(commit)
}
function main() {
const stamp = resolveStamp()
if (!stamp || !stamp.commit) {
// Should not happen — fromFallback() always provides a commit.
console.error(
"[write-build-stamp] ERROR: could not determine git commit.\n" +
" - $GITHUB_SHA not set\n" +
" - `git rev-parse HEAD` failed at " +
REPO_ROOT +
"\n" +
"Packaged builds require a git ref to pin first-launch install.ps1\n" +
"against. Run from a git checkout or set $GITHUB_SHA explicitly."
)
process.exit(1)
}
if (isFallbackCommit(stamp.commit)) {
console.warn(
"[write-build-stamp] WARNING: no git commit found (non-git checkout?).\n" +
" Using placeholder commit — the packaged app will fall back to the\n" +
" default branch for first-launch bootstrap. For production builds,\n" +
" run from a git checkout or set $GITHUB_SHA."
)
}
if (stamp.dirty) {
console.warn(
"[write-build-stamp] WARNING: working tree is dirty.\n" +
" Pinning to " +
stamp.commit.slice(0, 12) +
" but the packaged code may differ from that commit.\n" +
" Commit your changes before publishing this build."
)
}
const payload = {
schemaVersion: STAMP_SCHEMA_VERSION,
commit: stamp.commit,
branch: stamp.branch,
builtAt: new Date().toISOString(),
dirty: stamp.dirty,
source: stamp.source
}
mkdirSync(OUT_DIR, { recursive: true })
writeFileSync(OUT_FILE, JSON.stringify(payload, null, 2) + "\n", "utf8")
console.log(
"[write-build-stamp] wrote " +
relative(REPO_ROOT, OUT_FILE) +
" -> " +
stamp.commit.slice(0, 12) +
(stamp.branch ? " (" + stamp.branch + ")" : "") +
(stamp.dirty ? " [DIRTY]" : "") +
(stamp.source === "fallback" ? " [FALLBACK]" : "")
)
}
if (isMain(import.meta.url)) {
main()
}
@@ -0,0 +1,86 @@
import assert from 'node:assert/strict'
import { test } from 'vitest'
import {
FALLBACK_BRANCH,
FALLBACK_COMMIT,
fromCI,
fromFallback,
fromLocalGit,
isFallbackCommit,
resolveStamp
} from './write-build-stamp.mjs'
test('fromCI reads GITHUB_SHA / GITHUB_REF_NAME', () => {
assert.deepEqual(
fromCI({ GITHUB_SHA: 'a'.repeat(40), GITHUB_REF_NAME: 'release' }),
{ commit: 'a'.repeat(40), branch: 'release', dirty: false, source: 'ci' }
)
assert.equal(fromCI({}), null)
})
test('fromLocalGit returns null when git rev-parse fails', () => {
const stamp = fromLocalGit('/tmp/not-a-repo', () => null)
assert.equal(stamp, null)
})
test('fromLocalGit reads HEAD + branch + dirty status', () => {
const calls = []
const execFn = (cmd) => {
calls.push(cmd)
if (cmd === 'git rev-parse HEAD') return 'b'.repeat(40)
if (cmd === 'git rev-parse --abbrev-ref HEAD') return 'main'
if (cmd === 'git status --porcelain -uno') return ' M apps/desktop/package.json'
return null
}
assert.deepEqual(fromLocalGit('/repo', execFn), {
commit: 'b'.repeat(40),
branch: 'main',
dirty: true,
source: 'local'
})
assert.ok(calls.includes('git rev-parse HEAD'))
})
test('fromFallback uses the all-zero placeholder commit', () => {
assert.deepEqual(fromFallback(), {
commit: FALLBACK_COMMIT,
branch: FALLBACK_BRANCH,
dirty: false,
source: 'fallback'
})
assert.equal(isFallbackCommit(FALLBACK_COMMIT), true)
assert.equal(isFallbackCommit('a'.repeat(40)), false)
})
test('resolveStamp prefers CI over local git over fallback', () => {
const ci = resolveStamp({
env: { GITHUB_SHA: 'c'.repeat(40), GITHUB_REF_NAME: 'main' },
execFn: () => 'should-not-run'
})
assert.equal(ci.source, 'ci')
assert.equal(ci.commit, 'c'.repeat(40))
const local = resolveStamp({
env: {},
execFn: (cmd) => {
if (cmd === 'git rev-parse HEAD') return 'd'.repeat(40)
if (cmd === 'git rev-parse --abbrev-ref HEAD') return 'main'
if (cmd === 'git status --porcelain -uno') return ''
return null
}
})
assert.equal(local.source, 'local')
assert.equal(local.commit, 'd'.repeat(40))
assert.equal(local.dirty, false)
})
test('resolveStamp falls back when neither CI nor git is available', () => {
const stamp = resolveStamp({ env: {}, execFn: () => null })
assert.deepEqual(stamp, {
commit: FALLBACK_COMMIT,
branch: FALLBACK_BRANCH,
dirty: false,
source: 'fallback'
})
})