Commit Graph

191 Commits

Author SHA1 Message Date
kshitijk4poor
bc0758c393 fix(desktop): a renderer built under other VITE_*/NODE_ENV settings is never reused
Vite inlines every VITE_* variable into the renderer and NODE_ENV selects its mode, but the
desktop receipt recorded only sources and prepared inputs. After `VITE_PERF_PROBE=1 npm run
build`, the next plain build reused (and productCurrent certified) the renderer that still
carries the perf probe; a NODE_ENV change had the same gap.

The desktop build inputs now record the renderer environment (VITE_* plus NODE_ENV, unset
read as vite's production default), so both rendererCurrent and productCurrent require it to
match. Reported by Greptile on #135092.
2026-10-08 15:20:58 -07:00
kshitijk4poor
136a4282bb fix(desktop): an incomplete native tree restages on every build
Skipping stage-native-deps on an admitted receipt kept a tree staged without a piece forever:
get-windows (or its win32 x64 binding, which only the native installer inside staging can fetch),
the macOS get-windows helper, or the host HUD helper (no X11 toolchain on Linux). The receipt
identity cannot see the host fix that would complete it.

The receipt now records `degraded` from the staged tree itself (nativeTreeComplete: every piece
this host can produce for the target is present), and build.mjs reuses only a complete one
(nativeInputsComplete). Gaps no host fix closes — win32-arm64 has no get-windows prebuild, a
cross-target pack never builds the helpers — do not count. Judging the tree rather than each
builder's return value covers soft-fail paths that still return a path (the win32 binding).
2026-10-08 15:20:58 -07:00
kshitijk4poor
97260490e1 perf(desktop): skip restaging native inputs whose receipt still matches 2026-10-08 15:20:58 -07:00
kshitijk4poor
eeb8526569 test(desktop): a stamp-only rebuild reuses the renderer; tampered output or a source change recompiles 2026-10-08 15:20:58 -07:00
ethernet
4f9b741a3a fix(install-e2e): tolerate the update-result consume race and held lock files (#135261)
* fix(install-e2e): tolerate the update-result consume race and held lock files

Two HEAD -> NEXT legs of the stable-release matrix went red for harness reasons,
not product ones.

macOS (app-driven update): source-update-observer listed
.hermes-update-result.json and then read it. The relaunched app consumes
(unlinks) that file on boot, so the read could land after the unlink and throw
ENOENT, which launch-from-spec reported as a failed update although the update
receipt said success. A vanished file is now "not there", the same as before it
was listed.

Windows (user-state verify): verify-user-state hashed every judged file. The
gateway's cron ticker holds an msvcrt byte-range lock on cron/.tick.lock, so
open() raised PermissionError and the upgrade was reported as changing user
state. Proven on a real Windows host: base raises Errno 13, the fix records the
lock as a sentinel. A lock file is held by a live process and is never user
content, so it is recorded without being read. Any other unreadable entry now
surfaces as a ScanError that names the path instead of a bare errno.

* test(install-e2e): an unreadable user file still fails the scan with its path

Skipping lock files must not let a user file the verifier cannot read pass
silently. The test makes a real mode-000 file under memories/ and asserts
snapshot_home raises ScanError naming the path, with the PermissionError kept
as the cause. It goes red when the OSError -> ScanError wrapper is removed.
2026-10-08 17:37:28 -04:00
ethernet
baef00bf4c fix(smoke): check bundled Hermes home before sandbox setup
The smoke driver created .desktop-smoke-home inside the predicted Hermes
home before checking that the home was empty. Bundles with stamped env
that retained the explicit HERMES_HOME failed before Electron launched.

Resolve and admit the predicted home before any sandbox writes. Keep the
existing-profile refusal and provider seeding intact. Extend the seeding
contract for onboarding defaults and forced home clears.

Verified the old failure and fixed launch boundary on native Windows ARM64
with real driver imports and filesystem writes. Full package install/chat
acceptance still needs a fresh hosted smoke run.
2026-10-06 11:04:35 -04:00
Teknium
d1a7aa7c14 fix: date a macOS backend's start in UTC so the DST-end hour cannot misdate it
processStartTime read `ps -o lstart` in local time and rebuilt it with a
hand-written month table. In the hour that repeats when DST ends, local
01:30 names two instants and the reconstruction picked the first, putting a
backend started in the second 3600 s off its ledger create_time, so a valid
host backend was rejected. ps now renders lstart with TZ=UTC0 and the value
is parsed as GMT with Date.parse (V8 accepts the exact `ps` format,
including the double-spaced day), which also drops the month table.
2026-10-06 02:53:32 -07:00
Teknium
e4f862b7ca fix: grant the app-report waiver only to a listener proven to be the app's child
appVouchesForBackend tested `ownership !== 'host-ledger'`, so a backend
object that never had its ownership established (the field was optional;
untyped .mjs callers can build one) received the ancestry waiver. The
waiver's premise is ancestry, so it must be stated: localBackendProcess now
returns a BackendProcess whose `ownership` is required, and the waiver
requires `=== 'child'`.
2026-10-06 02:53:32 -07:00
Teknium
eda0fcaaa0 refactor(desktop-smoke): extract appVouchesForBackend from assertBackendOrigin
Moves the app-ancestry evidence predicate (process evidence unreadable,
not a ledger-attached host backend, app reported the same tree) and its
comment into a named helper. Same expression, same short-circuit order,
evaluated at the same point; behaviour unchanged. Brings
assertBackendOrigin's CC back under its main cap (24 -> 19).
2026-10-06 02:53:32 -07:00
Teknium
1946a105b2 test(desktop-smoke): bind ledger-attached backends to their own evidence
A host-ledger backend is not an Electron descendant, so the app-reported
root no longer stands in for its tree when process evidence is unreadable
(Windows). Ledger records must match the live process's start time
(create_time, 2 s like the product), and the ledger is read at the machine
root the app resolves for the home, where the product writes it.
2026-10-06 02:53:32 -07:00
Teknium
047f8aa46f style: format desktop smoke ownership regression 2026-10-06 02:53:32 -07:00
Teknium
35d0cb86e0 test: recognize shared host backends in desktop update smoke 2026-10-06 02:53:32 -07:00
liuhao1024
1d6b2786e2 fix(build): refuse a completed-install receipt whose packages lost their manifests
The reuse receipt check in node-deps.mjs accepted a tree where every locked
directory existed but package contents had been stripped (partial cleanup or
snapshot restore). npm trusts the hidden lockfile and never repairs such
entries, so every 'hermes update' skipped npm ci, the TUI build died on the
missing esbuild manifest, and the stale receipt kept the loop closed (#128935).
Probe each locked entry's package.json — the same file product builders
resolve tools through — so a gutted tree falls back to a real npm ci.
2026-10-05 22:09:20 -07:00
Brooklyn Nicholson
f0b9649032 fix(build): wait out Windows scanner holds when publishing products
publishDirectory moved a finished product into place with one renameSync.
On Windows an antivirus scanner opens every freshly written file, so the
rename onto build/native-deps (right after hud-modifier-monitor.exe is
compiled) or hermes_cli/web_dist failed EPERM and threw away a build that
took minutes.

retryHeld retries EPERM/EACCES/EBUSY for about 3s and raises every other
code at once. All three publication renames use it, as does the HUD
helper's staging rename. The previous product's backup now sits beside
the output instead of inside withProduct's scratch tree, so a rollback
that also fails no longer has its only copy deleted by scratch cleanup;
the error names where it is.

Co-authored-by: dskwe <6286305+dskwe@users.noreply.github.com>
Co-authored-by: juliorobertoalvarez <39749815+juliorobertoalvarez@users.noreply.github.com>
Co-authored-by: bighamx <10262931+bighamx@users.noreply.github.com>
Co-authored-by: lzmuhioin <1814826+lzmuhioin@users.noreply.github.com>
2026-10-05 17:08:32 -05:00
teknium1
a994f7e690 test(desktop): pin the chunk checker's bundle/harness verdict
A failure of the check itself (no Node, no VM module API) must never be
reported as a broken renderer chunk; assert the kind, not just the text.
2026-10-01 15:28:55 -07:00
Brooklyn Nicholson
b0b73ecad5 test(desktop): give the renderer syntax-check probe a real timeout
Four sequential node --check children (plus a spawnSync that must fail
finding a bogus binary) exceed vitest's 5s default on a cold run; the test
needs a 120s ceiling so it measures behaviour, not machine speed.
2026-10-01 15:28:55 -07:00
funky-xamarin
c7d193a127 fix(desktop): reuse running Node for renderer syntax checks 2026-10-01 15:28:55 -07:00
Brooklyn Nicholson
5690810779 fix(build): await the scratch-dir removal; drop an unused test import 2026-10-01 00:15:05 -05:00
Brooklyn Nicholson
6285f09fd6 test(build): pin the OS-metadata freshness skip and the ENOTEMPTY rmTree retry 2026-10-01 00:15:05 -05:00
PRATHAMESH75
64eca050ec fix(build): skip macOS Finder metadata in build freshness hashing (#122632)
Opening the checkout in Finder mid-build lets .DS_Store, AppleDouble ._*
sidecars or .localized land inside a hashed input dir (apps/desktop, apps/shared,
assets, scripts/build). treeHash then sees a new input and recordProduct throws
"Build inputs changed during compilation; retry the build", so `hermes update`
rebuilds the desktop app, fails closed, and never prints Update complete.

Finder metadata is never a build input, so skip it as a baseline in treeHash —
covering source, output and prepared trees uniformly rather than only the source
predicate. A real source change is still detected and still names nothing new to
hash, so clean checkouts (CI) hash byte-identically.
2026-10-01 00:15:05 -05:00
Halldrix
24fb52eb29 test(build): pin the mid-compile guard, the pre-build gate and the receipt clock (#123308)
The mid-compile guard test drives recordProduct directly: a clock-only restamp
must not throw, while a commit, payload or tag racing in must still fail it.

The pre-build gate tests cover the two ways a bundle could be left inconsistent:
a restamp after the build makes productCurrent report not current, and the
receipt records the clock the output baked rather than the live one, so a
restamp landing between the bake and the record is caught too. A receipt written
before the clock was recorded is documented as self-healing, and a missing or
unparsable stamp still hashes to a stable distinct value instead of throwing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-01 00:15:05 -05:00
Halldrix
f672e8c26c test(build): pin the racing restamp, the build clock and the stamp fallback (#123308)
The first test reproduces the issue's own shape: a second build restamps the
stamp mid-compile, and with the whole-file hash the build dies on "inputs
changed". A commit swapped in mid-compile must still throw, so the passing case
cannot be an artefact of a guard that stopped guarding.

The second drives buildDesktop with only `builtAt` moved: the receipt stays
current and the rebuild succeeds, while commit/payload/tag still invalidate. The
third covers the fallback: a missing and an unparsable stamp hash to distinct,
stable values instead of throwing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-01 00:15:05 -05:00
PRATHAMESH75
d436e79bae test(build): seed assets/ input before the junk-write assertion (#122632)
The freshness regression wrote assets/.localized into a hashed web input
dir the fixture never created, so the assertion exercised a
missing->directory transition of the assets input rather than the
junk-in-an-existing-dir case it meant to cover: treeHash goes from
'assets\0missing\0' to 'assets\0', the hash flips, and productCurrent
returns false. Seed assets/logo.svg before buildWeb so assets is an
established hashed dir at baseline; the later .localized is then skipped
by macMetadata and the hash stays byte-identical, keeping assets coverage
in the junk-metadata check.

Reported by @kokhlo.
2026-10-01 00:15:05 -05:00
Siddharth Balyan
5eea87882a Clarify: one question shape and one result shape on every surface (skip, cancel, timeout and undelivered are told apart) (#127760)
* refactor(desktop): split clarify-tool.tsx into a clarify/ folder

Pure moves, no behaviour change. Parsing, the question-card core
(shell, choice rows, question block), the delivery watchdog, and each
pending/settled card get their own file so the core can be reused.

* feat(clarify): one questions[] shape with per-question status and one outcome

The clarify tool now takes only questions=[{question, choices?, multi_select?}].
A wrong shape is a tool error that names the right one, so a model that
sends the old top-level question/choices corrects itself on the next call.

Every result has the same shape on every surface: each response carries
status (answered, skipped, unanswered) with user_response null unless
answered, and the result carries one outcome (submitted, cancelled,
timed_out, undelivered) plus an optional surface-written notice. The
timeout and cancel sentences that used to pose as the user's answer are
gone, and the compressor reads status instead of matching their prefixes.

The callback contract is callback(questions) -> {answers, outcome, notice?}
(None = skipped, missing qid = unanswered). tui_gateway settles a batch the
same way for the last lock, a cancel, an interrupt and the deadline, and
clarify.lock keeps a null answer as a skip. A multi-select answer that is
not a JSON array counts as one typed ("Other") answer. The tool-row preview
reads the first question.

* feat(clarify): every surface asks through the one question card

CLI: the batch panel is the only panel; Enter on an empty field skips a
question, Ctrl+C cancels and keeps the locked answers, the deadline returns
timed_out. -q and -z return undelivered with a no-user notice.

Ink TUI: the single-question prompt is gone; the card supports multi-select
(Space or a digit toggles, Enter locks, typed Other text joins the picks),
an empty submit skips, Esc cancels, and "Batch" names are dropped.

Desktop: the single-question card is deleted and its keyboard handling moved
into the one card (arrows, letters and digits, auto-advance, Enter picks then
confirms). Confirm enables at one answer; blank questions lock null; Skip and
a composer message cancel; the settled card shows No answer for unanswered
questions. The bots room card follows the same contract.

Messaging: one card per question with a "Reply skip to skip" line in every
locale; timeouts and delivery failures map to timed_out and undelivered.

* fix(clarify): cancelled for a released messaging card, labels win over the skip word

A prose reply to a messaging choice card, /new and session end released
the wait with "", which read as timed_out. They now resolve with a
CANCELLED marker and the tool gets outcome cancelled.

A typed reply that matches a choice label ("Skip") now resolves to that
choice; the skip word applies only when no choice matches.

The bots room card sends the picked labels as they are; the tool already
strips the recommendation label. Unused OUTCOMES and a new docstring and
comment are removed.

* fix(tui): re-editing a multi-select answer keeps its picks

The Ink card stored a multi-select answer as display text ("A, B"), so
revisiting the question put the whole string into Other and re-locked it
as one item. The answers map now keeps the raw JSON array, the card
restores the picks on revisit, and only the display lines format it.

Comments that earlier edits reworded are restored to their original
words, minus the phrases the change made wrong. The compute-host clarify
lock accepts None for a skipped question.

* test(clarify): align three checks with the one-answer confirm and the regenerated keys

The desktop Cmd+Enter test now expects a send with one answer (the blank
question locks null), the compressor test expects the single-answer summary
the code gives, and locales/_keys.desktop.json is regenerated for the
removed and added clarify strings.

* test(tui): the one-question card header is singular
2026-09-29 18:33:44 +05:30
Brooklyn Nicholson
8bcca88282 test(desktop): match the desktop.mjs compile step with native path separators
The --icons case found the compile command with endsWith('/scripts/build/desktop.mjs'),
which never matches the path.join-built argument on Windows, so the case failed with
TypeError instead of asserting. Match both separator styles.

Fixes https://github.com/NousResearch/hermes-agent/issues/125139
2026-09-27 08:34:17 -05:00
Brooklyn Nicholson
c0a0f097a8 fix(desktop): ship entry.ts as the Electron main bundle entry
The bundler rewrite predates #113247, and merging it back kept main.ts as
the esbuild entry. entry.ts, which picks the ozone platform and relaunches
before main loads, was no longer bundled: the WSLg Wayland relaunch never
ran. Bundle entry.ts again and run the built electron-main.mjs in a test
to prove the relaunch ships.
2026-09-27 06:26:35 -05:00
teknium1
5afba1e8d9 ci(install-e2e): keep one install-e2e-red issue in step with the scheduled matrix
A red scheduled run blocked nothing and told nobody. install-e2e-red.yml
runs after every scheduled "Install & Update E2E" run: red opens one issue
labelled install-e2e-red (or rewrites the open one's body in place) with the
red legs grouped by failure class and linked; the first green run closes
it. No per-run comment, never a second issue.

It is its own workflow_run workflow because install-e2e.yml is also called
by stable-release.yml with read-only permissions, and a nested job asking
for issues: write would fail that call at startup. workflow_dispatch with a
dry-run default previews the change for any run id.
2026-09-27 04:15:09 -07:00
teknium1
7605349f25 ci(install-e2e): run a path-filtered four-leg subset on pull requests
install-e2e.yml only ran on the clock, so nothing in front of a merge
installed a release and updated it on a real OS. A pull_request trigger,
path-filtered to the install/update surface (derived from 60 days of
update/install/pm commits), runs the new `pr` route of
generate-e2e-matrix.mjs with only the newest release tag sampled:

  linux   installer-script -> hermes-update (newest release -> PR)
  linux   installer-script -> hermes-update (PR -> NEXT)
  windows installer-script -> hermes-update (PR -> NEXT)
  macos   installer-script -> hermes-update (newest release -> PR)

The bundle-manifest validation job is skipped on PRs (bundled legs need
dispatch-only manifests). The full matrix stays on schedule and release.
2026-09-27 04:15:09 -07:00
Austin Pickett
30592c1c68 test(update): prove probe-era receipts reuse under the manifest npm version
The receipt key hashes the npm version. Cover the transition from the
child-probe lane to the manifest lane: an install completed under the
probed version must still be recognized as complete once the version is
read from npm's package.json (same string for standard npm), and that
reuse must hold when `--version` can no longer spawn — the #123933 lane
on a Windows Job Object — without running npm ci again. A changed npm
version still invalidates the receipt. Runs without symlinks so Windows
CI exercises it too.

Co-authored-by: JoaoMarcos44 <joaomarcosdias444@gmail.com>
2026-09-26 23:48:52 -04:00
liuhao1024
646c3c8ad5 fix(update): resolve npm's manifest through symlinks and fall back to the probe
npm_execpath can point through a symlink, and the manifest sits beside
the resolved CLI, never beside the link: resolve the realpath before
looking for package.json. Layouts without a readable manifest now fall
back to the pre-fix child probe instead of aborting with ENOENT.

Move the regression test to tests-js/node-deps.test.mjs (the module's
own suite) and cover the symlinked-execpath and fallback lanes there.
2026-09-26 23:48:52 -04:00
liuhao1024
00c8e5ca48 test(desktop): cover the one-entry batch clarify shape end to end
Since the questions[]-only schema (#95907) every single question is a
one-entry batch on both the tool args and the gateway wire, yet no test
exercised that shape (called out in #98645). Lock the behavior down at
both layers:

- unit: a one-entry batch renders the batch card (not a blank/spinner
  single card) both with the wire already parked and when the request
  lands after the tool row, and answers with the qid-keyed lock
- e2e: a SINGLE_BATCH trigger drives the real chain (composer -> gateway
  -> agent -> clarify tool -> clarify.request -> renderer) through mount,
  pick, confirm, and settle for questions.length === 1

The e2e mock's trigger routing also learns to scope its has-tool-result
guard to the answering turn's own question text: the existing any-tool-
result check would false-positive once a second scripted clarify shares
the conversation history.

Adapted to main: the mock server now lives in tests-js/scripts, and a batch
confirm answers with clarify.lock.
2026-09-26 18:00:36 -05:00
Hermes Agent
625f1d8926 fix(update): retry node dependency preparation after ENOTEMPTY 2026-09-26 17:59:59 -05:00
hermes-seaeye[bot]
aae6c2044e fmt(js): npm run fix on merge (#123390)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-09-26 03:39:27 +00:00
ethernet
4b7229d612 fix(icons): commit generated icons; installs and regular builds never render
User installs failed with 'resvg-py is missing' because the web/desktop
source builds rendered icons on whatever python was on PATH. The default
brand outputs are now committed; source_build, apps/desktop build.mjs and
the npm/docusaurus pre-hooks consume them directly. Flavored release
bundles (canary/commit) still render into their own product dir.

icons-freshness-check now regenerates and fails on any byte diff.
2026-09-24 19:17:34 -04:00
ethernet
3689d8c435 test(js): evaluate setup-pm and desktop cache gates, find legs by role
setup-pm-cache.test.mjs compared `if:` expressions as literal strings and
indexed `build-${platform}-${arch}-{release,commit}` by id. So the per-arch
split, or rewording a gate, broke it while the cache contract still held.

- A small evaluator for the Actions expression subset. Gates are asserted by
  how they behave:
  - The exact-key wheel save runs only for callers with extras or a test
    environment, and never together with the rolling restore.
  - The prune runs after a failure but not on cancel.
  - The upload needs a successful prune.
- Desktop build legs are the jobs that use the desktop-build-cache action,
  keyed by matrix target and cache-mode. A new check requires a write leg and
  a read leg for every native target. The write leg runs only for tag builds;
  the read leg runs for commit and channel builds.
- Candidate saves are evaluated: they run after a successful prepare of a
  trusted dispatch (a tag build, or a main push for the payload). They do not
  run on a failed prepare, a cancel, commit or channel builds, pull requests,
  other branches, or a foreign ref.
- Selection gates are found by SELECTED_BUILD_SUCCEEDED. Each needs validate
  plus one write and one read leg of a single target, and refuses a failed
  admission. The selection logic and the "only gates consume legs" invariant
  are covered in tests/ci/test_desktop_bundle_smoke.py.
2026-09-24 14:08:22 -04:00
ethernet
f43c38affc test(install-e2e): accept the current checker's countless update offer and the macOS installer marker
The current source checker reports updateAvailable with behind null when GitHub
compare cannot count staged commits; the app-update predicate demanded an integer
behind and refused every HEAD->NEXT leg. Require behind > 0 only for the historical
shape without updateAvailable.

v2026.6.19's DMG bootstrap runs the same install.sh that writes .install_method,
so its macOS leg saw a dirty tree. Share the Linux driver's guarded exclude through
source-driver.sh and apply it before every app-driven macOS update.
2026-09-24 10:42:43 -04:00
ethernet
d79e50e4f2 test: retry historical Desktop source check only on concurrent Git ref lock 2026-09-24 08:58:06 -04:00
ethernet
5495412874 test: drive legacy Desktop updates against staged source with clean install marker 2026-09-24 08:58:06 -04:00
ethernet
359f0d3e7b test: preserve historical venv Desktop branch probing without PM launcher 2026-09-24 08:33:13 -04:00
ethernet
6edc9051c0 test(e2e): combine PM launcher and historical Electron Git probes 2026-09-24 07:12:03 -04:00
ethernet
63aa0a48db fix: stage historical Desktop source checks on Git main 2026-09-24 07:10:57 -04:00
ethernet
40b089c777 test(e2e): select staged source branch through isolated POSIX launcher
Packaged Electron rejects NODE_OPTIONS --require, so the preload never reached the Desktop source check. Wrap only the E2E installation launcher and route its source-check call to the real staged Git main with an explicit branch; other launcher calls remain unchanged. Keep production channel resolution fail-closed.
2026-09-24 06:50:05 -04:00
ethernet
1695fab334 fix: run Windows Desktop source checks through PM launcher 2026-09-24 06:29:22 -04:00
ethernet
2120e8728d test(e2e): route managed Desktop checks to staged Git main 2026-09-24 06:19:09 -04:00
ethernet
792a4e63a1 test: route Desktop source update checks to staged Git main 2026-09-24 05:36:59 -04:00
ethernet
0c1b34826f fix(install-e2e): settle OLD source desktop before launch 2026-09-24 03:13:54 -04:00
ethernet
01e6e89ca2 fix: restore JS checks for session mock and matrix lint 2026-09-24 02:50:25 -04:00
ethernet
29c56a27d4 fix: align CI tests and workflows with PM build contracts 2026-09-24 02:03:59 -04:00
ethernet
156efd4ba2 test(install-e2e): the desktop smoke reads the install commit with the driver's real git
The HEAD -> NEXT Windows legs take every git off PATH so the product must
provision its own; readInstallationCommit spawned bare `git` and died with
spawnSync git ENOENT before the chat checkpoint could judge the product.
2026-09-24 01:05:17 -04:00
ethernet
26c6307046 ci(install-e2e): route selects specific legs, not just an OS
route already means "which legs run"; a leg name is the most specific
route. Presets keep their meaning (all, the linux trio, windows-desktop,
macos-desktop, the bundled three); any other value selects legs by name,
so a leg name, a fragment of one, or the job name GitHub shows
("<leg> / e2e") runs just those legs. A route that selects nothing fails
instead of producing a green empty run.

The generator is now the one interpreter of route for source legs: the
linux/windows/macos jobs run when it selected legs for them, replacing
three hand-kept preset lists. Dispatch route becomes a string input (a
choice cannot take a leg name) and reaches the gen step through env, never
interpolated into the script.
2026-09-23 23:42:01 -04:00