Skip to content

perf(cli): answer --help without booting the engine, draw the startup spinner first - #1758

Merged
code-yeongyu merged 7 commits into
mainfrom
perf/8371-help-fast-path
Sep 16, 2026
Merged

code-yeongyu merged 7 commits into
mainfrom
perf/8371-help-fast-path

Conversation

@code-yeongyu

@code-yeongyu code-yeongyu commented Sep 16, 2026

Copy link
Copy Markdown
Owner

What

--help booted the whole engine before it could print one line: migrations, the settings manager, ModelRuntime.create() (models.json + models-store.json + availability), the full resource load (extensions plus skills, prompt templates, themes, context files) and an AgentSession. The reporter of code-yeongyu/oh-my-openagent#8371 measured 47.8s for omo --help on Windows (node 24); on an Apple M4 Pro the same boot costs 790ms warm on bun, 959ms on node, and 8.8-13.6s with a cold cache.

Two layers, both in packages/coding-agent:

  1. Flags-only help in main.ts. A plain --help stops right after the CLI paths are resolved: extensions are loaded for their registered flags only (cli/help-extension-flags.ts: a DefaultResourceLoader with skills, prompt templates, themes and context files disabled - no ModelRuntime, no SessionManager, no AgentSession), help is printed, and the flag set is recorded in <agentDir>/cache/help-flags.json. Every full launch refreshes that record from its own loaded extensions, so the cache stays warm without a help run.
  2. Engine-free help in cli.ts. The next plain --help is answered from that cache before cli-main (the engine graph) is imported. The cache is validated by the engine version and by mtime/size stamps of every discovery input - each loaded extension file and its directory, the user/project extensions directories, settings.json, trust.json, the --extension paths - so an upgrade, an added or edited extension, a settings edit or a trust decision rejects the entry and the normal path refills it. Output is byte-identical to the cold path.

Project trust for a help screen is override → recorded trust.json decision → trusted only when the project carries nothing trust-requiring. It never prompts and never loads project-local extension code the user has not already trusted (regression case included).

Also fixed on the way, same root cause family (work that blocks the terminal): the startup spinner's first frame waited on a 120ms timer that the synchronous extension imports starve, so on a real pty it appeared at 2.27s - after the entire load, one frame before the TUI. start() now draws the first frame itself; the grace delay gates the animation only.

Measurements (Apple M4 Pro, bun 1.4.2 / node v26.7, hyperfine N=10 warm, --shell=none)

surface before after
senpi --help with the omo plugin, bun, cache hit 1157 ms ± 171 27.8 ms ± 0.9 (41x)
same, node 995 ms ± 36 59.1 ms ± 2.2 (17x)
same, bun, cache miss (fresh agent dir each run) 1157 ms 969 ms ± 38 (extension load kept by design)
omo --help through the installed launcher (beta.65), bun 790 ms ± 22 ≈ launcher 19 ms + cached child (lands with the omo pin bump)
interactive startup: first byte on the pty 2271 ms see the "after" pty table in the linked dossier

The phase attribution behind these numbers (PI_TIMING tables, module-graph import costs, CPU profiles) is recorded in the tracking issue.

Tests

  • test/suite/regressions/help-fast-path.test.ts (new, 7 cases; RED at 6e6dcd6 → GREEN at d080d82 on a clean worktree with a real install+build): first help loads extensions once and never constructs the model runtime, leaves the project directory untouched; second help is byte-identical with no extension load; changed/added extension, changed settings each invalidate; untrusted project extension never loaded nor listed; --no-extensions loads nothing.
  • test/startup-loading-indicator.test.ts: grace-delay cases flipped to the synchronous first frame (RED at 77967cf → GREEN at 50a891a).
  • test/cli-inprocess-fast-path.test.ts: the inspector case used --help as its probe; a cached help is now answered before the isolation decision (no agent runs, no socket to hand over), so it probes with a launch that reaches the agent.
  • Adjacent: args.test.ts, 678-pi-rules-help-env.test.ts, valid-cwd-guard.test.ts green.

Follow-ups (outside this PR)

  • omo: the plugin's task component creates <cwd>/.omo/senpi-task/** at factory time, so even a flags-only help touches the project directory; that moves to first use on the omo side.
  • The remaining ~400-480ms of every cold launch is the unbundled engine module graph (~1600 files) and the omo plugin bundle parse (129ms warm → 3s cold); bundling is the next lever and is documented, not attempted here.

Refs code-yeongyu/oh-my-openagent#8371


Summary by cubic

Answers --help from a cached flag set instead of booting the whole engine, and draws the startup spinner's first frame immediately. A plain --help used to build migrations, the model runtime, the full resource load, and an agent session before printing; it now prints byte-identical output from <agentDir>/cache/help-flags.json (refreshed by every full launch), cutting warm --help from 790ms to 28ms on bun and 959ms to 59ms on node (M4 Pro; 47.8s on Windows in code-yeongyu/oh-my-openagent#8371).

  • A cache miss loads extensions for their flags only, skipping skills, prompt templates, themes, context files, the model runtime, and sessions.
  • Cache entries are invalidated by the engine version and mtime/size stamps of every discovery input, so an upgrade, extension change, settings edit, or trust decision refills them.
  • Help never prompts for project trust and never runs project-local extension code that isn't already trusted.
  • The startup spinner writes its first frame in start(); the 120ms grace delay now gates only the animation, since the synchronous extension imports previously starved the timer and the first pty byte arrived at 2.27s.

Written for commit 2496395. Summary will update on new commits.

Review in cubic

`--help` builds the whole runtime before printing: migrations, settings,
ModelRuntime (models.json + models-store.json + availability), the full
resource load, and an AgentSession. Measured on this host: 790ms warm on
bun, 959ms on node, 8.8-13.6s cold; the reporter of
code-yeongyu/oh-my-openagent#8371 measured 47.8s on Windows.

The probe extension appends one line per factory invocation, so the line
count is the number of times extensions were loaded - the contract is
pinned by observable side effects, never by timings.

Refs code-yeongyu/oh-my-openagent#8371
Help needs the static usage text plus the flags extensions registered,
yet main.ts reached printHelp only after building the whole runtime:
ModelRuntime (models.json + models-store.json + availability), a session
manager, the full resource load and an AgentSession. Measured on this
host: 790ms warm on bun, 959ms on node, 8.8-13.6s cold; the reporter of
code-yeongyu/oh-my-openagent#8371 measured 47.8s on Windows.

Two layers:

- main.ts resolves a plain --help from a flags-only extension load
  (no skills, prompt templates, themes, context files, models or
  session) and records the flags in <agentDir>/cache/help-flags.json.
  Every full launch refreshes that record too, so it stays warm without
  a help run of its own. A help screen never prompts for project trust
  and never runs project-local extension code the user has not already
  trusted.
- cli.ts answers the next --help from that cache before the engine
  module graph is imported: 26-28ms on this host, byte-identical output.
  The cache is validated by mtime/size stamps of every discovery input
  (extension files and directories, settings, trust.json, the CLI
  --extension paths) and by the engine version, so an upgrade, an
  added/changed extension, a settings edit or a trust decision rejects
  it and the normal path refills it.

Refs code-yeongyu/oh-my-openagent#8371
…gent

The inspector case used --help as its probe. A cached --help is now
answered before the isolation decision - no agent runs for a help screen,
so there is no debugger socket to hand over - which left that case
asserting a second process that correctly never appears.

Refs code-yeongyu/oh-my-openagent#8371
…ork it covers

On a real pty the first spinner byte arrived at 2.27s, one frame before the
TUI took over: the 120ms grace timer cannot fire while the synchronous
extension imports it was meant to announce are running, so the indicator
drew only after they finished. The first frame must be written in start()
and setPhase() must render before any timer fires.

Refs code-yeongyu/oh-my-openagent#8371
The grace delay kept fast startups flash-free, but the work the spinner
covers is synchronous module loading, which starves every timer until it
is done: measured on a real pty, the first frame landed at 2.27s, after
the whole extension load ran on a blank terminal. start() now writes the
first frame itself and the grace delay gates the animation only; resume()
redraws the same way.

Refs code-yeongyu/oh-my-openagent#8371
@code-yeongyu
code-yeongyu merged commit 5386a91 into main Sep 16, 2026
26 checks passed
@code-yeongyu
code-yeongyu deleted the perf/8371-help-fast-path branch September 16, 2026 08:12
code-yeongyu added a commit that referenced this pull request Sep 16, 2026
… path

dist/cli.js is the one tracked file under dist/ (a bun global install
needs it before any build runs). #1758 changed src/cli.ts, so the release
job's build rewrote it and scripts/release.mjs refused to run on a dirty
tree (run 35072616238). Same convention as f37f589: the rebuilt file
ships with the source change.

Refs code-yeongyu/oh-my-openagent#8371
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant