You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
C — withdrawn, do not start. See "The premise was wrong" below.
src/cli.ts eager closure on 527a56a: 295 modules, from 365. The prediction for A+B was 294; the extra module is main moving underneath, not a modelling error.
Measured startup effect: none. Details below. A side-finding — a stale APPROVED_OVER_CEILING row for command-registry/src/planned-operations.ts — was real and landed separately as #2373.
The premise was wrong
This issue opened on the sentence "every agent-device invocation — --version included — eagerly evaluates 365 modules through src/cli.ts." That is true of the source graph and false of the shipped artifact, and the difference is the whole point.
tsdown rolls the ~1,670 source modules into ~371 dist/src/*.js chunks. dist/src/cli.js statically imports 19 of them. Building the base and the branch and diffing those two chunk sets:
chunks removed: (none)
chunks added: (none)
The 70 source modules A and B took off the eager path were already merged into chunks the CLI imports regardless. Nothing stopped loading. The size report agreed across two independent runs:
run 1
run 2 (rebased, smaller diff)
--version
20.6 → 20.6 ms
27.3 → 27.5 ms
--help
55.9 → 58.3 ms (+2.4)
76.8 → 75.4 ms (−1.4)
The --help delta flipped sign on a smaller diff, and the baseline moved 55.9 → 76.8 ms on unchanged code — 37% runner-to-runner. Both readings are noise; there is no effect to measure in either direction.
So the eager-closure gate measures the source graph, not what the shipped CLI evaluates. It remains a real invariant — layering, test-suite import cost, its own no-growth rule — but it does not predict CLI startup, and this issue presented it as though it did.
Why C is withdrawn rather than deferred
C proposed splitting commands/family/registry.ts (dominates 82 modules on the current head) so twelve command families stop loading their runtime for a metadata read. Two things kill it as written:
The payoff is source-only. Those 82 modules would have to correspond to whole chunks dist/src/cli.js stops importing. Nothing suggests they do, and the A+B result is direct evidence that source-module reductions of this shape do not survive bundling.
The cost is a redesign. Only 2 of the 12 families expose metadata separately; the other 10 declare metadata and run together per command, exactly as family/types.ts:31 describes. Separating them changes how commands are authored across ten families.
A ten-family authoring change for an unmeasurable win is a bad trade. Anyone revisiting this should diff the chunk graph first — grep -o 'from"\./[^"]*"' dist/src/cli.js | sort -u, before and after a prototype — and re-scope from what actually loads.
What A and B were actually worth
Not performance. Both are layering corrections that stand on the repo's own rules: a metadata module should not reach into runtime for a constant contracts owns (gestures.ts only re-exports it, and says so in a comment), and a string-membership check should not load a runtime barrel when the leaf has zero imports and the subpath-per-file rule exists. Cost: +111 B of download for providers.ts becoming its own chunk.
The follow-up this exposed
The genuinely open question is a measurement, not a refactor: of the 19 chunks dist/src/cli.js imports, how many does --version actually need?cli.js already lazy-imports for dispatch (./connection.js, ./cli-help.js, ./generic.js), so the seam exists; what is missing is evidence about which of the 19 are avoidable. That is worth its own issue if anyone wants the startup win this one failed to deliver.
Original analysis, preserved — accurate about the source graph, wrong about what it implies
Purpose
Every agent-device invocation — --version included — eagerly evaluates 365 modules through src/cli.ts. Measured on main at 5ba4ac7 with pnpm depgraph (value edges only, so type-only and await import edges are excluded); the number matches the eager-closure gate's own reading of src/cli.ts exactly.
It is the outlier by a wide margin. Across the 239 entries the gate measures:
These are not three unrelated wins. They are one defect class: a module that needs vocabulary — a constant list, a predicate over it — reaches it through a runtime barrel and pays for the runtime. Wave 3 (#2335) made this both visible and fixable: the vocabulary now has package homes, and the subpath-per-file rule gives each one a cheap door.
Dominator analysis from src/cli.ts — modules that stop loading if that one module goes lazy:
module
dominates
commands/family/registry.ts
118
commands/interaction/index.ts
49
commands/interaction/metadata.ts
43
commands/interaction/runtime/gestures.ts
40
cli/connection/provider-policy.ts
32
packages/provider-webdriver/src/index.ts
31
A — one import line, −40 modules (365 → 325)
src/commands/interaction/metadata.ts:42 imports SCROLL_INPUT_DIRECTIONS from ./runtime/gestures.ts. Every other import in that file is vocabulary; this one line is the odd one out, and it drags 40 modules of gesture runtime into every CLI run. gestures.ts does not even own the constant — it re-exports it from @agent-device/contracts/scroll-gesture, with a comment saying the vocabulary lives there "so the public API can declare ScrollOptions without depending on this command runtime".
B — one subpath, −31 modules (325 → 294)
src/cli/connection/provider-policy.ts imports a constant map, a pure predicate over it and one type from @agent-device/provider-webdriver — whose only export was ".", so the whole WebDriver runtime loaded to answer "is this string a known provider name". packages/provider-webdriver/src/providers.ts has zero imports.
C — family registry facet split, −82 further (294 → 212)
src/commands/family/registry.ts value-imports all twelve command families to build one array. Its seven non-test consumers each want a different facet and each pays for all twelve families' runtime.
Evaluated and not scheduled
The daemon code-signature cost (~620 → ~1,461 modules walked, ~2.9 ms → ~7.5 ms cached, landed with refactor(daemon): move four pure leaves to their kits #2347) is a real startup regression but a different mechanism — a workspace source walk in walkDaemonCodeGraph, not an import closure. It still wants its own issue.
packages/platform-android/src/mechanics.ts (177) and the platform-apple façades (100–120) are the next-largest closures, but they are not on the CLI's hot path.
A lint rule banning vocabulary→runtime edges: only one instance of the smell existed, so a gate would be enforcement ahead of evidence.
The premise was wrong
This issue opened on the sentence "every
agent-deviceinvocation —--versionincluded — eagerly evaluates 365 modules throughsrc/cli.ts." That is true of the source graph and false of the shipped artifact, and the difference is the whole point.tsdownrolls the ~1,670 source modules into ~371dist/src/*.jschunks.dist/src/cli.jsstatically imports 19 of them. Building the base and the branch and diffing those two chunk sets:The 70 source modules A and B took off the eager path were already merged into chunks the CLI imports regardless. Nothing stopped loading. The size report agreed across two independent runs:
--version--helpThe
--helpdelta flipped sign on a smaller diff, and the baseline moved 55.9 → 76.8 ms on unchanged code — 37% runner-to-runner. Both readings are noise; there is no effect to measure in either direction.So the eager-closure gate measures the source graph, not what the shipped CLI evaluates. It remains a real invariant — layering, test-suite import cost, its own no-growth rule — but it does not predict CLI startup, and this issue presented it as though it did.
Why C is withdrawn rather than deferred
C proposed splitting
commands/family/registry.ts(dominates 82 modules on the current head) so twelve command families stop loading their runtime for a metadata read. Two things kill it as written:dist/src/cli.jsstops importing. Nothing suggests they do, and the A+B result is direct evidence that source-module reductions of this shape do not survive bundling.metadataandruntogether per command, exactly asfamily/types.ts:31describes. Separating them changes how commands are authored across ten families.A ten-family authoring change for an unmeasurable win is a bad trade. Anyone revisiting this should diff the chunk graph first —
grep -o 'from"\./[^"]*"' dist/src/cli.js | sort -u, before and after a prototype — and re-scope from what actually loads.What A and B were actually worth
Not performance. Both are layering corrections that stand on the repo's own rules: a metadata module should not reach into runtime for a constant
contractsowns (gestures.tsonly re-exports it, and says so in a comment), and a string-membership check should not load a runtime barrel when the leaf has zero imports and the subpath-per-file rule exists. Cost: +111 B of download forproviders.tsbecoming its own chunk.The follow-up this exposed
The genuinely open question is a measurement, not a refactor: of the 19 chunks
dist/src/cli.jsimports, how many does--versionactually need?cli.jsalready lazy-imports for dispatch (./connection.js,./cli-help.js,./generic.js), so the seam exists; what is missing is evidence about which of the 19 are avoidable. That is worth its own issue if anyone wants the startup win this one failed to deliver.Original analysis, preserved — accurate about the source graph, wrong about what it implies
Purpose
Every
agent-deviceinvocation —--versionincluded — eagerly evaluates 365 modules throughsrc/cli.ts. Measured onmainat5ba4ac7withpnpm depgraph(value edges only, so type-only andawait importedges are excluded); the number matches the eager-closure gate's own reading ofsrc/cli.tsexactly.It is the outlier by a wide margin. Across the 239 entries the gate measures:
src/cli.ts)platform-android/src/mechanics.ts)Where those 365 live:
commands92,contracts65,provider-webdriver25,host-kit24,cli19,core18,kernel17,selectors15,snapshot14,ad-script13.These are not three unrelated wins. They are one defect class: a module that needs vocabulary — a constant list, a predicate over it — reaches it through a runtime barrel and pays for the runtime. Wave 3 (#2335) made this both visible and fixable: the vocabulary now has package homes, and the subpath-per-file rule gives each one a cheap door.
Dominator analysis from
src/cli.ts— modules that stop loading if that one module goes lazy:commands/family/registry.tscommands/interaction/index.tscommands/interaction/metadata.tscommands/interaction/runtime/gestures.tscli/connection/provider-policy.tspackages/provider-webdriver/src/index.tsA — one import line, −40 modules (365 → 325)
src/commands/interaction/metadata.ts:42importsSCROLL_INPUT_DIRECTIONSfrom./runtime/gestures.ts. Every other import in that file is vocabulary; this one line is the odd one out, and it drags 40 modules of gesture runtime into every CLI run.gestures.tsdoes not even own the constant — it re-exports it from@agent-device/contracts/scroll-gesture, with a comment saying the vocabulary lives there "so the public API can declareScrollOptionswithout depending on this command runtime".B — one subpath, −31 modules (325 → 294)
src/cli/connection/provider-policy.tsimports a constant map, a pure predicate over it and one type from@agent-device/provider-webdriver— whose only export was".", so the whole WebDriver runtime loaded to answer "is this string a known provider name".packages/provider-webdriver/src/providers.tshas zero imports.C — family registry facet split, −82 further (294 → 212)
src/commands/family/registry.tsvalue-imports all twelve command families to build one array. Its seven non-test consumers each want a different facet and each pays for all twelve families' runtime.Evaluated and not scheduled
walkDaemonCodeGraph, not an import closure. It still wants its own issue.packages/platform-android/src/mechanics.ts(177) and theplatform-applefaçades (100–120) are the next-largest closures, but they are not on the CLI's hot path.