Skip to content

perf(hooks): SessionStart p95 7.1 s from about 26 per-plugin prerequisites rows; use one shared async runner #6633

Closed

Description

@kyle-sexton

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": 10

Files 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

  1. One shared prerequisites runner: a single SessionStart row (in one always-enabled plugin, for example harness-ops) that checks node once and the per-plugin binaries from each plugin's prerequisites.json, and prints one combined notice. The per-plugin rows are then removed.
  2. 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 the additionalContext and systemMessage fields from the hook's JSON response to Claude on the next conversation turn ... Unlike a synchronous hook's systemMessage, 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 -l returns 0 (or 1 for the runner).
  • /harness-ops:observability latency SessionStart p50/p95 over at least 100 fires before and after (OTEL hook_execution_complete, event SessionStart).
  • Kill node from PATH in a test session: one combined notice appears, once per session.

Activity

  1. added
    needs-triageNot yet classified. Floor until a type and one priority tier are set.
    on Oct 8, 2026
  2. github-actions commented on Oct 8, 2026

    @github-actions
    Contributor

    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 node from 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:

    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.

    • SessionStart p50 and p95 over at least 100 fires, before and after, on the Windows lane.
    • With node removed from PATH in a test session, one combined notice appears, once per session.

    #6633 (comment)

    No duplicates found.

    Automated triage, applied by the lanes App.

  3. added
    needs-humanHuman-in-the-loop required; autonomous sessions must not resolve items carrying this.
    on Oct 8, 2026
  4. added theissue type on Oct 15, 2026
  5. added
    status: needs-decisionAwaiting a human or maintainer judgment call.
    and removed
    needs-triageNot yet classified. Floor until a type and one priority tier are set.
    on Oct 15, 2026
  6. kyle-sexton commented on Oct 19, 2026

    @kyle-sexton
    MemberAuthor

    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.

  7. added
    status: confirmedA maintainer confirmed the item is real and in scope; ready to be worked.
    and removed
    status: needs-decisionAwaiting a human or maintainer judgment call.
    needs-humanHuman-in-the-loop required; autonomous sessions must not resolve items carrying this.
    on Oct 19, 2026
  8. kyle-sexton commented on Nov 2, 2026

    @kyle-sexton
    MemberAuthor

    Claimed by the Backlog HITL host-run lane: building the shared prerequisites runner (option A) on branch host-run/6633-shared-prereq-runner.

  9. kyle-sexton commented on Nov 2, 2026

    @kyle-sexton
    MemberAuthor

    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

    • Claude Code 2.1.316 on the native Windows lane of the affected desktop, reached from its WSL lane by route R1 of /fleet:reach. The Windows lane's own login was used; no credential was read.
    • Which-half probe: one 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".
    • Baseline: 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.py reads every Hooks: 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 sh half runs.

    • The debug log says Claude Code launches hooks through Git Bash: Using bash path: "...\Git\bin\bash.exe".
    • The probe marker received only sh-half PPID=<n>. PPID is set under Git Bash, so ${PPID:+exit} ends the row before PowerShell.
    • So the issue's figure of about 147 ms per row for the PowerShell half does not apply on this host.

    Row census, one startup: 48 SessionStart rows in all (debug log: "Registered 159 hooks from 71 plugins"):

    • 28 sh … prerequisites.sh node-notice rows
    • 10 node … prerequisites.mjs probe rows
    • 10 other rows

    Baseline, n = 30 startups (milliseconds Claude Code reports for each row; the slowest row of each kind per startup):

    row kind p50 p95 max
    node-notice sh rows (slowest of 28) 1216 1497 1577
    prerequisites.mjs probe rows (slowest of 10) 1265 1603 1672
    all rows (the SessionStart critical path) 2622 2906 3030
    • The slowest row was the same in all 30 of 30 startups: explainer-video's hooks/exec-bash.mjs … install-python-deps.sh SessionStart row. Speech's and animation's install-python-deps.sh rows came next. All 30 runs exited 0 with all 48 rows.
    • Two readings.
      • The prerequisite rows are not on the critical path: at the median, the slowest of them finishes about 1.2 s into a batch of about 2.6 s.
      • Measured alone, the sh half 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.
      • Only an after-run with the same harness can say how much A saves.
    • Validity caveats.
      • n = 30, short of the 100 fires done-when 4 names.
      • Startups were headless -p, and --debug-file adds its own overhead.
      • All runs came from one host in one sitting.
      • The issue's 7.1 s p95 came from OTEL on an earlier version, so this baseline does not compare with it.
    • No OTEL store. Neither lane has .claude/observability/otel/cc-logs.json, so /harness-ops:observability latency (the method done-when 4 names) cannot run. hook-latency.sh exits 2 with "no logs store". The debug-file harness stands in until telemetry is turned on.

    Done-when

    • Debug-log result posted (which half runs, CLI version). Met. Only the sh half runs under Git Bash bash.exe on Claude Code 2.1.316, Windows lane; evidence above.
    • One async harness-ops row checks node and each enabled plugin's prerequisites.json and 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, and scripts/node-notice-rows.test.sh pins the rows. The build therefore changes both contracts and that test, plus 29 hooks.json files and their changelog fragments.
    • Per-plugin rows removed; a plugin without harness-ops still gets a node notice, or the README says it does not. Unmet; depends on the item above.
    • SessionStart p50/p95 compared over at least 100 fires before and after. Unmet. The before-side has n = 30 on the debug-file harness. The after-side needs the change installed on the Windows lane.

    Build notes for the follow-up (decided from evidence; no owner input needed)

    • The owner's 2026-10-19 choice (A) stands. This run did the "confirm first" step: its result changes the expected saving, not the design.
    • ADR 0019 holds. harness-ops reads only other plugins' declared prerequisites.json data, 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.
    • Node check. It stays in sh with the existing session latch and runs node only when node is present. Edit canonical lib/prerequisites.sh and resync the copies with scripts/sync-shared-copies.sh. Keep the per-plugin lib copies, because the /x:check skills call them.
    • Git-hook drift. Keep detecting it by spawning each plugin's own lib/prerequisites.mjs probe <root> as a child of the one async row, so the installer lookup (HERE) still resolves inside that plugin.
    • Kill switches. A probe honours 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.
    • Plugin discovery. Find enabled plugins through check-prerequisites.mjs's settings-merge path, not its claude plugin list --json spawn, which would start a nested claude on every session start.
    • Async output. Before relying on the async row's systemMessage, check which output fields an async hook can deliver: https://code.claude.com/docs/en/hooks#run-hooks-in-the-background.
    • Separate finding: three rows set the critical path. explainer-video, speech and animation each have an exec-bash.mjs … install-python-deps.sh SessionStart 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-hook verify subprocesses. Only the after-measurement can say what this saves in wall-clock time.

    • harness-ops carries the fleet's two prerequisites rows.
      • Its existing shell-form node-notice row is unchanged, byte for byte, with the existing latch: the node check stays in sh, falls back to PowerShell, and needs no node.
      • A new exec-form row, node hooks/prerequisites-fleet.mjs, runs the probe of every enabled plugin whose prerequisites.json declares the new optional session_probe key, and prints their notices as one systemMessage.
      • Both rows use the startup|resume|clear|fork matcher.
    • 28 plugins lose their node-notice row, and 10 also lose their probe row. instruction-placement's hooks/hooks.json is removed, since only those two rows were left in it. testing's generator (gen-hook-filters.sh) no longer emits the row.
    • session_probe replaces 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' own jq entries included).
    • Kill switches. harness-ops cannot see another plugin's CLAUDE_PLUGIN_OPTION_*, so the runner reads the option from pluginConfigs in the user and managed settings files. This is channel F of docs/contracts/hook-config-delivery, reusing lib/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 declared userConfig default. Anything but true skips the plugin, and so does a settings file that cannot be read.
      • Boolean options are stored as JSON booleans: the user settings file on this host holds "block_convention_gate_enabled": false. readOperatorOptions throws 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.
    • Git-hook drift detection survives. Each probe runs as a child process: process.execPath <plugin>/lib/prerequisites.mjs probe <plugin>.
      • The child gets that plugin's CLAUDE_PLUGIN_ROOT and CLAUDE_PLUGIN_DATA (<config>/plugins/data/<name-marketplace>), so the installer lookup (HERE), the probe's latch and hook::notice_once all see the plugin's own directories.
      • harness-ops' own CLAUDE_PLUGIN_OPTION_* variables are removed from the child's environment.
      • The children run in parallel, each with a 10 s timeout; the row's timeout is 15 s.
    • Plugin discovery reuses rootsFromSettings from check-prerequisites.mjs (enabledPlugins in the user and project settings, plus installed_plugins.json), with the config directory taken from harness-ops' own install path. It never runs claude plugin list, and a test proves no claude process starts. Project scope comes from CLAUDE_PROJECT_DIR, so the row spawns no git.
    • Contracts updated together:
      • docs/contracts/prerequisites/README.md: the file key, the probe registration, the hook-notices table and its matcher paragraph, residuals, and the sync rationale.
      • The schema file.
      • docs/contracts/hook-budget/README.md, Scope, onFailure prose and table row.
      • docs/contracts/check-placement/README.md.
      • docs/plugin-philosophy.md, the Hooks row.
      • The READMEs of disk-hygiene, guardrails and markdown-format.
      • The descriptions of nine hooks.json files.
      • The check-killswitch-hoist.sh comment.
      • The per-plugin tests that pinned the old rows (actionlint, bash-format, biome-format, go-format, markdown-format, powershell-format, ruff-format, typos-format, guardrails ×2, disk-hygiene, eol-normalizer, testing).
      • One changelog fragment per touched plugin: 73 plugins. Most only carry synced copies of prerequisites.mjs, matching the precedent of feat(prerequisites): accept an exclusive below bound on detect.version #9800.

    Evidence for the open spec points

    • What an async hook delivers. It delivers systemMessage and additionalContext, but to Claude on the next turn, not to the user: "Claude Code delivers the additionalContext and systemMessage fields from the hook's JSON response to Claude on the next conversation turn … Unlike a synchronous hook's systemMessage, neither field is shown to you." Source: hooks § How async hooks execute, read 2026-11-02. In -p mode, Claude Code also cancels async hooks still running at teardown (§ Configure an async hook).
    • So the issue's premise that an async notice "loses nothing by arriving on the next turn" does not hold. The notices are written for the user ("only the user can install node"). The runner row is built synchronous, which is the no-regression default (see Q1). The baseline above shows the prerequisite rows finish about 1.2 s into a batch of about 2.6 s at p50, so one synchronous row is not expected to set the critical path. Only the after-run can confirm that.

    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 every session_probe gate is a userConfig option. 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:
      • two plugins' notices print as one message, and no claude process starts;
      • an undeclared plugin is not probed;
      • a disabled plugin is not probed;
      • a kill switch set to false is honored, both as a JSON boolean and as a string;
      • a kill switch set to true runs the probe; with the booleans opt-in reverted, this case fails;
      • a declared default of false is honored;
      • the latch lands in the probed plugin's data directory, and a second run in the same session prints nothing;
      • the hook prints exactly one JSON line and exits 0.
    • lib/prerequisites.test.mjs: session_probe is accepted and rejected the way the schema says (312 pass).
    • CHECK_DATA_ROOT=<wt> scripts/check-affected.sh --tests on 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

    • Debug-log result posted (which half runs, CLI version). Met in the run above.
    • One async harness-ops row checks node and each enabled plugin's prerequisites.json and prints one combined notice, with the latch moved in. Partly met:
      • Met: harness-ops checks node, with the latch, and probes each enabled plugin that declares session_probe, printing one combined probe notice.
      • Unmet as worded:
        • The node check and the fan-out are two rows, not one. The node row must stay shell form so it can run without node; the fan-out row is exec form so it runs on a PowerShell-only host as well.
        • The rows are synchronous, not async (Q1).
    • Per-plugin rows removed; a plugin without harness-ops still gets a node notice, or the README says it does not. Met:
      • The rows are removed from 28 plugins. The probe rows are now session_probe declarations.
      • docs/contracts/prerequisites/README.md#hook-notices says that without harness-ops no session-start notice reports a missing node, and a missing hook dependency is reported only at the point of use.
      • It also names what the runner cannot see: --plugin-dir plugins, managed-scope enabledPlugins, options set only through --settings, and server-managed policy.
    • SessionStart p50/p95 compared over at least 100 fires before and after. Unmet: the coordinator runs the after-measurement once the change is installed on the Windows lane, using 6633-method/win-baseline.sh and analyze.py.

    Owner question

    Q1. Should harness-ops' prerequisites runner row be async?

    • Context: the 2026-10-19 decision chose "one async SessionStart row". The hooks page shows an async row's systemMessage reaches Claude on the next turn and is not shown to the user, and -p sessions cancel it.
    • Recommendation: (a) keep both rows synchronous, as built. Basis:
      • hooks § How async hooks execute;
      • this record's baseline: the prerequisite rows are off the critical path at p50;
      • the notices are user-facing: "only the user can install node" (lib/prerequisites.sh header).
    • Alternatives:
      • (b) make the runner row async. The notice then reaches only Claude, which relays it and can offer the fix under the prerequisites contract's "offer the fix" rule.
      • (c) keep the node row synchronous and make the fan-out row async.
    • What it unblocks: the merge. The 2026-10-19 decision recorded an async row, so the PR is held until the owner answers. A "(b)" or "(c)" answer adds "async": true to one row, plus the contract sentence and the test pin.

    Branch

    host-run/6633-shared-prereq-runner at 8ecdff4c42315cdd707d0232fb57ef70b2b95320 (pushed). No PR opened. The merge waits on Q1, so open the PR with the do-not-merge label.

    GitHub calls

    • One issue read went out while the hub breaker read tripped. The breaker cleared at its 02:43Z reset.
    • After that: one comments read, the claim comment (#issuecomment-6567913336) and one push, each behind the gate.

    Target labels: add none; remove none.

  10. kyle-sexton commented on Nov 2, 2026

    @kyle-sexton
    MemberAuthor

    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".

    • Finding during the build: an async hook's systemMessage and additionalContext reach Claude on the next turn, and "Unlike a synchronous hook's systemMessage, 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.
    • Cost of staying synchronous: the baseline puts the prerequisite rows off the SessionStart critical path (the slowest finishes about 1.2 s into a 2.6 s batch at p50), so async would save little.
    • What stays the same: the primary design, choice A (one shared row in harness-ops in place of about 38 per-plugin rows). Only the async mechanism changes, so this is a mechanism finding, not an intent change.
    • Reversal: flipping to async is a one-line "async": true on the runner row, plus the contract sentence and the test pin. It cannot be made a user option, because async is a static hooks.json boolean 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).

  11. added
    needs-triageNot yet classified. Floor until a type and one priority tier are set.
    and removed
    status: confirmedA maintainer confirmed the item is real and in scope; ready to be worked.
    on Nov 2, 2026
  12. 27 remaining items

  13. github-actions commented on Nov 2, 2026

    @github-actions
    Contributor
    No description provided.
  14. kyle-sexton commented on Nov 2, 2026

    @kyle-sexton
    MemberAuthor

    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.

    • SessionStart p50 and p95 over at least 100 fires, before and after, on the Windows lane.
    • With node removed from PATH in a test session, one combined notice appears, once per session.
  15. added
    needs-triageNot yet classified. Floor until a type and one priority tier are set.
    and removed
    status: needs-decisionAwaiting a human or maintainer judgment call.
    on Nov 2, 2026
  16. added
    host-runNeeds an unattended run on a host; the host runner takes it, not a person or the issue lane.
    and removed
    agent-readyFully specified and briefed; eligible for autonomous pickup from the frontier.
    on Nov 2, 2026
  17. removed
    needs-triageNot yet classified. Floor until a type and one priority tier are set.
    on Nov 2, 2026
  18. added
    status: confirmedA maintainer confirmed the item is real and in scope; ready to be worked.
    and removed
    status: readyTriaged, unblocked, and fully specified; eligible to pick up.
    on Nov 2, 2026
  19. melodic-automation-lanes commented on Nov 2, 2026

    @melodic-automation-lanes
    Contributor

    The latest triage verdict holds nothing: admission moved the issue to status: confirmed; its comment says why.

  20. kyle-sexton commented on Nov 3, 2026

    @kyle-sexton
    MemberAuthor

    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

    • Trees. Both sides load from --plugin-dir copies on the Windows drive, so they load the same way.
      • The installed plugins are at 1784017c3d, which already carries the fix, so neither side can use the installed copies.
      • Before is 0cf78a2017^ (git archive).
      • After is main at acc0c05fd64cc7b9493be56dcb2c78307e0bf633.
      • Each side passes the 59 melodic-software plugins that the lane enables. All 59 are present in both trees, and each manifest name matches its directory.
      • Every counted fire's debug log has 59 from --plugin-dir overrides installed version lines.
      • The 9 enabled plugins from other marketplaces load from their installs on both sides.
    • Fires. Each fire is one headless claude.exe -p "Reply with the single word ok." --debug-file <file>, run from a Windows temp directory. Fires run one at a time.
      • The sides alternate in blocks of 25. The first fire after each switch is a warm-up and is discarded, which makes 8 warm-ups.
      • The scripts are in .work/backlog-to-zero/host-run-records/6633-method/: build-trees.sh, win-ab.sh and analyze2.py.
      • analyze2.py is analyze.py with three more row kinds: the new prerequisites-fleet.mjs row, the install-python-deps rows, and the slowest prerequisites row per fire.
    • Metric. Per startup, the metric is the slowest Hooks: SessionStart:startup [...] finished with status N (Xms) row. That is the SessionStart critical path, the same measure as the n=30 baseline in 6633.md.
    • Host guards.
      • A fire starts only while the 1-minute load in WSL is under 12. The load at each start is recorded.
      • Fires are 5 s apart.
      • The run stops on a HOST-PRESSURE line in the gh-monitor events log. That file did not exist during the run.
      • Results: 2 pauses (load 12.00 and 12.53, both around 21:08Z), 0 stops, 0 failed fires. The run took 20:21Z to 21:19Z.

    Result: SessionStart, n = 100 fires per side, all rows exit 0

    measure (ms) before p50 before p95 after p50 after p95
    SessionStart critical path (slowest row) 2928 4392 887 1149
    slowest prerequisites row 1546 3130 874 1149
    install-python-deps rows (slowest) 2928 4392 791 1016
    whole -p turn, wall clock 11743 14045 10453 13338
    • Row count: 49 SessionStart rows per startup before, and 12 after, in all 100 fires on each side.
    • Load at start: mean 3.82 (max 11.6) before, and mean 3.39 (max 10.38) after.
    • Slowest row: before, it was a pydeps row in 100 of 100 fires. After, it was the harness-ops fan-out row in 82 fires and a pydeps row in 18.

    Read the table with these caveats:

    Result: node off PATH, one combined notice once per session

    Setup: node-off.ps1 removes the fnm entries from PATH, and Get-Command node then finds nothing. It runs three fires on the after tree with --output-format stream-json --verbose.

    fire harness-ops systemMessage
    1: new session id 1
    2: --resume of the same id 0 (the session latch held)
    3: another new id 1
    • Met: one combined notice, once per session. The notice reads "harness-ops: node is not on PATH, so hooks that launch through node (this plugin's and others') do not run. Install Node.js ..., restart Claude Code, then run /harness-ops:prerequisites."
    • Also seen: 7 exec-form node SessionStart rows fail to spawn in every fire, resume included.
      • Each exits with status 1 and the stderr Executable not found in $PATH: "node".
      • The rows: harness-ops prerequisites-fleet.mjs, disk-hygiene resume-notice.mjs, and exec-bash.mjs in animation, explainer-video, playwright, session-flow and speech.
      • The notice explains these failures, but each one still arrives as its own hook error.
      • Whether the interactive UI shows those errors to the user was not observed: UNVERIFIED, since only headless runs were made.
    • Outside this repo: openai-codex's own node row exits 127.

    Target labels: add none; remove none.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    host-runNeeds an unattended run on a host; the host runner takes it, not a person or the issue lane.priority: mediumReal value, no hard deadline; normal backlog flow.status: confirmedA maintainer confirmed the item is real and in scope; ready to be worked.work-class: scopedA briefed fix or small feature; blast radius bounded by the brief, tests exist.

    Type

    Fields

    Work status

    confirmed

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions