Repository navigation
Description
Related: #6244 (Windows performance audit tracker), #6251 (matcher fix, closed), #6130 (node-notice exit 127), #4373 (hook fan-out umbrella).
Problem
Every plugin that needs a prerequisite check registers its own SessionStart row. 26 hooks.json files carry a prerequisites.sh / prerequisites.ps1 node-notice row, and 8 formatter plugins add a prerequisites.mjs probe row. Each row starts its own process tree, and SessionStart gates the first response. The hooks docs say: "SessionStart runs on every session, so keep these hooks fast" (https://code.claude.com/docs/en/hooks#sessionstart).
Measured
Method: OTEL hook_execution_complete.total_duration_ms for SessionStart on the windows lane (melo-desk-001), plus direct timing of one row on the same host (date +%s%N around the command, 3 runs).
| what | value |
|---|---|
| SessionStart wall-clock, p95 | 7.1 s (go-faster run 20261008T152359Z, hooks area) |
| plugin SessionStart rows with a prerequisites check | 26 hooks.json files (grep -l prerequisites.ps1 plugins/*/hooks/hooks.json) |
powershell -NoProfile -ExecutionPolicy Bypass -File lib/prerequisites.ps1 node-notice ... alone |
196 / 158 / 158 ms |
sh lib/prerequisites.sh node-notice ... alone |
31 / 30 ms |
the whole row as written, run under bash -c |
52 / 50 / 49 ms |
bare node -e 0 / bare bash -c true |
52 ms / 29 ms |
Discrepancy to settle first: the row is sh prerequisites.sh node-notice ...; ${PPID:+exit}; powershell ... prerequisites.ps1 .... Under Git Bash PPID is set, so the row exits before the PowerShell half and costs about 50 ms, not about 150 ms. The reported ~147 ms per row (22 plugins) applies only where the PowerShell half runs, which is the #6130 case (/bin/sh is not bash). Confirm which half runs inside Claude Code hook launches on this host (a debug log of one startup shows it) before sizing the saving.
Either way the rows are ~26 separate process launches that each answer the same question (is node on PATH), and they run before the first response.
Where
Example (plugins/harness-ops/hooks/hooks.json:507-526, same shape in 25 other plugins, e.g. plugins/eol-normalizer/hooks/hooks.json, plugins/actionlint/hooks/hooks.json:57):
"command": "sh \"${CLAUDE_PLUGIN_ROOT}/lib/prerequisites.sh\" node-notice /harness-ops:check; ${PPID:+exit}; powershell -NoProfile -ExecutionPolicy Bypass -File \"${CLAUDE_PLUGIN_ROOT}/lib/prerequisites.ps1\" node-notice /harness-ops:check",
"timeout": 10Files with the row: actionlint, animation, autonomy, bash-format, biome-format, context-budget, context-guard, desktop-notification, discovery, disk-hygiene, eol-normalizer, explainer-video, go-format, guardrails, harness-ops, instruction-placement, markdown-format, multi-agent, powershell-format, rate-limit-guard, ruff-format, session-flow, source-control, speech, testing, typos-format.
Proposed fix
- One shared prerequisites runner: a single SessionStart row (in one always-enabled plugin, for example harness-ops) that checks
nodeonce and the per-plugin binaries from each plugin'sprerequisites.json, and prints one combined notice. The per-plugin rows are then removed. - Keep the runner rows synchronous (no
"async": true). The notice exists to tell the person a tool is missing, and an async hook's output never reaches them: "After the background process exits, Claude Code delivers theadditionalContextandsystemMessagefields from the hook's JSON response to Claude on the next conversation turn ... Unlike a synchronous hook'ssystemMessage, neither field is shown to you." (https://code.claude.com/docs/en/hooks#how-async-hooks-execute). An async row would also break the last check under "Verify after the fix" (one combined notice appears). The saving comes from step 1, collapsing about 38 processes to 2; making the rows async would add little to it.
Amended 2026-11-02 by an agent session. The first version of step 2 asked for "async": true (owner's choice on 2026-10-19), on the premise that the notice loses nothing by arriving on the next turn. The current hooks docs show that premise is false, so step 2 changed. The owner directed sessions to resolve open questions through the standing direction (s1: decide from evidence), and the change stays within the issue's intent: fewer startup processes, with the warning still reaching the person. It is a one-line revert if the owner wants async back.
Basis for keeping the row cheap: https://code.claude.com/docs/en/hooks#sessionstart.
Risk to check: a plugin that disables itself without node (the notice's job) must still surface the warning once; the notice latch (lib/prerequisites.sh:15-40) has to move into the shared runner.
Expected saving
Start-up process count drops from about 26 to 1 (plus the 8 formatter probes folded in). If the PowerShell half runs on the affected hosts: about 22 x 147 ms of CPU removed, and the 7.1 s p95 should fall by whatever share the rows hold on the critical path (hooks run in parallel, so the p95 improves through less contention rather than a sum). Where the half is skipped: about 26 x 50 ms of CPU. The wall-clock saving needs the after-measurement below.
Verify after the fix
grep -l 'prerequisites.ps1' plugins/*/hooks/hooks.json | wc -lreturns 0 (or 1 for the runner)./harness-ops:observability latencySessionStart p50/p95 over at least 100 fires before and after (OTELhook_execution_complete, eventSessionStart).- Kill
nodefrom PATH in a test session: one combined notice appears, once per session.
Metadata
Metadata
Assignees
Labels
Type
Fields
Work status
Activity
github-actions commentedon Oct 8, 2026
Proposed work class: work-class: scoped
Basis: automated intake triage; the lanes App applied this class.
Size: Small
Complexity: Low
Uncertainty: Known
Flags: none
Blocked by: none
The shared runner shipped in #12267. What remains is two Windows-lane measurements: SessionStart p50/p95 over at least 100 fires, and a check that removing
nodefrom PATH gives one combined notice per session. Both need a host run, so a person or host lane has to do them. No code change is pending.The latest recorded decision, which settles the open questions:
#6633 (comment)
No duplicates found.
Automated triage, applied by the lanes App.
kyle-sexton commentedon Oct 19, 2026
Decision recorded.
Question: 28 plugins each register their own SessionStart prerequisites row. Where should the single shared runner live? Options: (A) one async SessionStart row in harness-ops; (B) keep per-plugin rows but make each async; (C) leave as is.
Owner's answer (2026-10-19): "go w/ recommendations", which was (A): one async SessionStart row in harness-ops. First confirm which half of the row runs on the affected host.
kyle-sexton commentedon Nov 2, 2026
Claimed by the Backlog HITL host-run lane: building the shared prerequisites runner (option A) on branch host-run/6633-shared-prereq-runner.
kyle-sexton commentedon Nov 2, 2026
Host run. On the native Windows lane, a debug-logged startup confirmed which half of the prerequisites row runs, and 30 sequential headless startups gave a SessionStart baseline. A later run built the shared prerequisites runner and pushed its branch; the merge waits on owner question Q1.
What ran
/fleet:reach. The Windows lane's own login was used; no credential was read.claude.exe -p "Reply with the single word ok." --debug-file <file> --settings <probe.json>. The probe settings added a SessionStart row in the same shape as the real one:sh -c 'echo sh-half >> marker'; ${PPID:+exit}; powershell -NoProfile -Command "Add-Content marker ps-half".win-baseline.sh 30 before, which runs 30 of the same headless startups one at a time, without the probe settings, each with--debug-file.analyze.pyreads everyHooks: SessionStart:startup [...] finished with status N (Xms)line and keeps the slowest row of each kind per startup. Both scripts are in the gitignored.work/backlog-to-zero/host-run-records/6633-method/, so the after-run can repeat the same method.Results
Which half runs (done-when 1). Only the
shhalf runs.Using bash path: "...\Git\bin\bash.exe".sh-half PPID=<n>.PPIDis set under Git Bash, so${PPID:+exit}ends the row before PowerShell.Row census, one startup: 48 SessionStart rows in all (debug log: "Registered 159 hooks from 71 plugins"):
sh … prerequisites.sh node-noticerowsnode … prerequisites.mjs proberowsBaseline, n = 30 startups (milliseconds Claude Code reports for each row; the slowest row of each kind per startup):
shrows (slowest of 28)prerequisites.mjs proberows (slowest of 10)hooks/exec-bash.mjs … install-python-deps.shSessionStart row. Speech's and animation'sinstall-python-deps.shrows came next. All 30 runs exited 0 with all 48 rows.shhalf takes about 30 ms (issue body). Inside the batch, the same rows report about 0.84 to 1.6 s, which points to process-spawn contention among 48 parallel rows. That contention can also slow the critical-path row.-p, and--debug-fileadds its own overhead..claude/observability/otel/cc-logs.json, so/harness-ops:observability latency(the method done-when 4 names) cannot run.hook-latency.shexits 2 with "no logs store". The debug-file harness stands in until telemetry is turned on.Done-when
shhalf runs under Git Bashbash.exeon Claude Code 2.1.316, Windows lane; evidence above.prerequisites.jsonand prints one combined notice, with the latch moved in. Unmet: not built in this run's time box. The repository's own contracts require a row in each plugin today:docs/contracts/prerequisites/README.md:156-165,docs/contracts/hook-budget/README.md:174-175,296, andscripts/node-notice-rows.test.shpins the rows. The build therefore changes both contracts and that test, plus 29hooks.jsonfiles and their changelog fragments.Build notes for the follow-up (decided from evidence; no owner input needed)
prerequisites.jsondata, and no plugin needs harness-ops to work. Without harness-ops, the only loss is an advisory notice, which done-when 3 lets the README state.shwith the existing session latch and runsnodeonly when node is present. Edit canonicallib/prerequisites.shand resync the copies withscripts/sync-shared-copies.sh. Keep the per-plugin lib copies, because the/x:checkskills call them.lib/prerequisites.mjs probe <root>as a child of the one async row, so the installer lookup (HERE) still resolves inside that plugin.CLAUDE_PLUGIN_OPTION_<NAME>, which only that plugin's own hook process sees. Find where harness-ops can read other plugins' options before the probe rows are removed. Otherwise a plugin a user switched off starts showing notices again.check-prerequisites.mjs's settings-merge path, not itsclaude plugin list --jsonspawn, which would start a nestedclaudeon every session start.systemMessage, check which output fields an async hook can deliver: https://code.claude.com/docs/en/hooks#run-hooks-in-the-background.exec-bash.mjs … install-python-deps.shSessionStart row. Removing the prerequisite rows does not touch them.Build
A later run built the shared runner on branch
host-run/6633-shared-prereq-runner(Claude Code 2.1.316 on the WSL lane). The after-measurement is not part of this run.What changed
Rows: about 38 SessionStart rows become 2. Processes: about 38 become 2, plus one child for each enabled plugin that declares
session_probe(at most 10). Each child still runs its own detection and git-hookverifysubprocesses. Only the after-measurement can say what this saves in wall-clock time.sh, falls back to PowerShell, and needs no node.node hooks/prerequisites-fleet.mjs, runs the probe of every enabled plugin whoseprerequisites.jsondeclares the new optionalsession_probekey, and prints their notices as onesystemMessage.startup|resume|clear|forkmatcher.hooks/hooks.jsonis removed, since only those two rows were left in it. testing's generator (gen-hook-filters.sh) no longer emits the row.session_probereplaces each probe row with declared data. The 10 plugins that had a probe row declare it:{ "run_if_unset_or_true": "<KILL_SWITCH>" }, or{}for instruction-placement. harness-ops reads only that data and runs each plugin's own code, so ADR 0019 holds. A plugin without the key is never probed, so plugins that never had a probe row get no new notices (harness-ops' ownjqentries included).CLAUDE_PLUGIN_OPTION_*, so the runner reads the option frompluginConfigsin the user and managed settings files. This is channel F ofdocs/contracts/hook-config-delivery, reusinglib/plugin-options.mjs, and the user settings file is located from the plugin's own install path, not from the environment. If the option is unset there, the runner uses its declareduserConfigdefault. Anything buttrueskips the plugin, and so does a settings file that cannot be read."block_convention_gate_enabled": false.readOperatorOptionsthrows on non-strings, so it gains an opt-in{ booleans: true }. Its three existing callers are unchanged, and the change is synced to their copies.process.execPath <plugin>/lib/prerequisites.mjs probe <plugin>.CLAUDE_PLUGIN_ROOTandCLAUDE_PLUGIN_DATA(<config>/plugins/data/<name-marketplace>), so the installer lookup (HERE), the probe's latch andhook::notice_onceall see the plugin's own directories.CLAUDE_PLUGIN_OPTION_*variables are removed from the child's environment.rootsFromSettingsfromcheck-prerequisites.mjs(enabledPluginsin the user and project settings, plusinstalled_plugins.json), with the config directory taken from harness-ops' own install path. It never runsclaude plugin list, and a test proves noclaudeprocess starts. Project scope comes fromCLAUDE_PROJECT_DIR, so the row spawns nogit.docs/contracts/prerequisites/README.md: the file key, the probe registration, the hook-notices table and its matcher paragraph, residuals, and the sync rationale.docs/contracts/hook-budget/README.md, Scope,onFailureprose and table row.docs/contracts/check-placement/README.md.docs/plugin-philosophy.md, the Hooks row.hooks.jsonfiles.check-killswitch-hoist.shcomment.prerequisites.mjs, matching the precedent of feat(prerequisites): accept an exclusive below bound on detect.version #9800.Evidence for the open spec points
systemMessageandadditionalContext, but to Claude on the next turn, not to the user: "Claude Code delivers theadditionalContextandsystemMessagefields from the hook's JSON response to Claude on the next conversation turn … Unlike a synchronous hook'ssystemMessage, neither field is shown to you." Source: hooks § How async hooks execute, read 2026-11-02. In-pmode, Claude Code also cancels async hooks still running at teardown (§ Configure an async hook).Tests
Each new test fails on the old tree and passes on the branch:
scripts/node-notice-rows.test.sh, rewritten: only harness-ops carries a node-notice row, no plugin carries a probe row, harness-ops has one exec-form runner row, and everysession_probegate is auserConfigoption. Against the old tree it gave 4 FAIL and 5 ok; on the branch, 9 ok.plugins/harness-ops/hooks/prerequisites-fleet.test.mjs, 9 cases:claudeprocess starts;booleansopt-in reverted, this case fails;lib/prerequisites.test.mjs:session_probeis accepted and rejected the way the schema says (312 pass).CHECK_DATA_ROOT=<wt> scripts/check-affected.sh --testson the committed head: 14 checks passed, ruff skipped (no Python changed), and every runnable shell suite passed; 85 Python and outside-node suites are left to their CI lanes. An earlier full run of the selected suites (affected-tests.sh --run) found one failure,markdown-format.test.sh, which still pinned the old probe row; it was fixed, along with the matching blocks in actionlint, bash-format, biome-format and ruff-format, and those five suites pass.Done-when
prerequisites.jsonand prints one combined notice, with the latch moved in. Partly met:session_probe, printing one combined probe notice.session_probedeclarations.docs/contracts/prerequisites/README.md#hook-noticessays that without harness-ops no session-start notice reports a missingnode, and a missing hook dependency is reported only at the point of use.--plugin-dirplugins, managed-scopeenabledPlugins, options set only through--settings, and server-managed policy.6633-method/win-baseline.shandanalyze.py.Owner question
Q1. Should harness-ops' prerequisites runner row be async?
systemMessagereaches Claude on the next turn and is not shown to the user, and-psessions cancel it.lib/prerequisites.shheader)."async": trueto one row, plus the contract sentence and the test pin.Branch
host-run/6633-shared-prereq-runnerat8ecdff4c42315cdd707d0232fb57ef70b2b95320(pushed). No PR opened. The merge waits on Q1, so open the PR with thedo-not-mergelabel.GitHub calls
tripped. The breaker cleared at its 02:43Z reset.Target labels: add none; remove none.
kyle-sexton commentedon Nov 2, 2026
Decision recorded. (agent; not an owner decision; resolved under the owner's 2026-11-02 direction, verbatim: "I'm going to bed, resolve any questions through:" followed by the standing direction s1-s10)
The shared prerequisites runner's rows stay synchronous. The owner's 2026-10-19 decision recorded "one async SessionStart row".
systemMessageandadditionalContextreach Claude on the next turn, and "Unlike a synchronous hook'ssystemMessage, neither field is shown to you" (hooks § Run hooks in the background). These notices exist to tell the user about a missing node or git-hook drift, so an async row would defeat their purpose."async": trueon the runner row, plus the contract sentence and the test pin. It cannot be made a user option, becauseasyncis a statichooks.jsonboolean and${user_config.*}tokens are not allowed there (docs/plugin-philosophy.md, Hooks row).Shipped in #12267. Still open on #6633: the after-measurement (SessionStart p50/p95 over at least 100 fires on the Windows lane, using the record's method).
27 remaining items
github-actions commentedon Nov 2, 2026
kyle-sexton commentedon Nov 2, 2026
Decision recorded.
The owner chose option A on 2026-10-19 ("go w/ recommendations": one shared SessionStart row in harness-ops). It shipped in #12267; the row is synchronous, because an async row's notice is not shown to the person, which the issue's own "one combined notice appears" check needs. This issue stays open for the two Windows-lane checks below, measured with the debug-file harness on both sides because neither lane has an OTEL store, with the before-side repeated to at least 100 fires. Written by the issue-triage session.
noderemoved from PATH in a test session, one combined notice appears, once per session.melodic-automation-lanes commentedon Nov 2, 2026
The latest triage verdict holds nothing: admission moved the issue to
status: confirmed; its comment says why.kyle-sexton commentedon Nov 3, 2026
Host run: the two Windows-lane checks left on #6633, measured after #12267. This run checks the two items in the 2026-11-02 21:15Z decision comment on #6633 against the shared prerequisites row that #12267 merged at
0cf78a2017. Lane: native Windows on melo-desk-001. CLI: Claude Code 2.1.317 at the start and at the end of the run.Method
--plugin-dircopies on the Windows drive, so they load the same way.1784017c3d, which already carries the fix, so neither side can use the installed copies.0cf78a2017^(git archive).acc0c05fd64cc7b9493be56dcb2c78307e0bf633.from --plugin-dir overrides installed versionlines.claude.exe -p "Reply with the single word ok." --debug-file <file>, run from a Windows temp directory. Fires run one at a time..work/backlog-to-zero/host-run-records/6633-method/:build-trees.sh,win-ab.shandanalyze2.py.analyze2.pyisanalyze.pywith three more row kinds: the newprerequisites-fleet.mjsrow, theinstall-python-depsrows, and the slowest prerequisites row per fire.Hooks: SessionStart:startup [...] finished with status N (Xms)row. That is the SessionStart critical path, the same measure as the n=30 baseline in6633.md.Result: SessionStart, n = 100 fires per side, all rows exit 0
install-python-depsrows (slowest)-pturn, wall clockRead the table with these caveats:
shrow alone went from 1378 to 221 ms at p50.--plugin-dirloading. The repeat at n=100 is the before side to use.@inlinechanges plugin state. The copies use<name>-inlinedata dirs, and thepluginConfigskeyed@melodic-softwaredo not reach them, so option defaults apply. Both sides share these conditions.installed_plugins.jsonand settings to find plugin roots, so it probes the copies at1784017c3d, not the--plugin-dircopies. That affects which probe code runs, not the row count.Result: node off PATH, one combined notice once per session
Setup:
node-off.ps1removes the fnm entries fromPATH, andGet-Command nodethen finds nothing. It runs three fires on the after tree with--output-format stream-json --verbose.systemMessage--resumeof the same idnodeSessionStart rows fail to spawn in every fire, resume included.Executable not found in $PATH: "node".prerequisites-fleet.mjs, disk-hygieneresume-notice.mjs, andexec-bash.mjsin animation, explainer-video, playwright, session-flow and speech.noderow exits 127.Target labels: add none; remove none.