launcher-select¶
Provenance¶
- Source:
.spec/spexcode/spec-cli/sessions/lifecycle/launch/launcher-select/spec.md - Source SHA-256:
fcbba2096211a1f0aa3480bf0d1662177103690517a716aae86c055de70b1e4a
launcher-select¶
How a worker is brought up has TWO facts: WHICH harness ([[harness-adapter]] — claude / codex / opencode / pi)
and WHICH command actually launches it (a login reclaude, an API-key claude-glm, a bespoke wrapper). A
launcher fuses those two into ONE named profile, so the human picks a single thing per session and the
harness rides along for free. Every launcher is a NAMED entry in spexcode.json / spexcode.local.json's
sessions.launchers map — a { harness?, cmd } pair keyed by a portable name the human chooses
(claude-glm, reclaude, …); harness defaults to claude. claude and codex are NOT a special
built-in tier resolved from an env var or a claudeCmd/codexCmd config field: [[spex-init]] SEEDS each selected
harness as an ordinary named launcher. Interactive harnesses preserve their normal permission model
(claude, codex, opencode, and pi all use their plain command); the independent [[opencode-headless]]
runtime is the deliberate exception and seeds opencode --auto, because a terminal-free run cannot stop for
an interactive permission prompt. After seeding, every entry is edited, renamed, or removed like any other
launcher. A project that intentionally wants another auth wrapper (reclaude) or automatic-permission command
declares it as an explicit launcher choice in spexcode.json or the gitignored spexcode.local.json; clean
init never grants those permissions silently. There is NO runtime env or harness-specific branch that rewrites
a launcher's command. The complete
launcher registry therefore lists exactly the config's real launchers, and two names can never resolve to the
same command as ghost duplicates; the dashboard applies only [[launcher-visibility]]'s adapter-capability
projection on top. Because a launcher NAMES a harness, picking a launcher is the ONLY user-facing launch
selection. The old free-standing harness pick is gone.
sessions.defaultLauncher names the profile a session with no explicit choice uses; it is required for any
no-choice create. Omitting it is a configuration error for those create paths, reported with the repair: write
sessions.defaultLauncher in spexcode.json or spexcode.local.json. There is no ambient fallback to a
claude launcher — claude is just another configured name, so a default (like every launcher name) must
resolve to a real sessions.launchers entry or fail loud, never silently choosing an auth/config-dir path the
human did not name. Host-specific absolute commands belong in the gitignored spexcode.local.json, never in
the committed file — a launcher name is portable, its cmd is a machine fact.
Selection at create time. spex session new "…" --launcher <name> picks it on the CLI (threaded through
createSession/newSession and the POST /api/sessions body); the dashboard New-Session form shows a
launcher pop-out picker sourced from GET /api/settings — a clean pill button wearing the selected
launcher's harness vendor mark + name (no caret, no label; its tooltip names spexcode.json /
spexcode.local.json as where launchers change) that opens a viewport-centred pop-out card over a light
backdrop (not an anchored dropdown). The card contains one row per dashboard-visible launcher: its harness
glyph + name and its complete cmd as read-only display text. The entire row is ONE pick target: a click anywhere on it —
the cmd line included — picks the launcher and closes the pop. The cmd never behaves as a surface of
its own (no control, no independent text-selection region: a cmd click that merely started a text
selection instead of picking read as a broken row). So a human can
inspect exactly what a launcher runs before picking it, without any edit surface — config files stay the sole
place a cmd is written. That endpoint reports { launchers: [{ name, harness, cmd, headless }], default };
the list is already narrowed by [[launcher-visibility]]'s committed dashboard policy, while the capability
marker still identifies any revealed headless row. The command rides the payload only as display data (the
dashboard sits behind the deployment's gateway auth). The mobile
composer keeps a plain native launcher select — the pop-out is desktop chrome. The picker's INITIAL selection
is always a visible launcher choice: a still-valid remembered (per-browser) pick wins, else a visible configured
default, else the first visible launcher in the list. That last case is not an implicit backend fallback — the
dashboard sends the selected launcher name explicitly. The seeded claude/codex profiles are ordinary
configured entries (and a default may name one of them), never an implicit no-choice fallback.
A resolved launcher fixes the session's harness; an unknown launcher name is rejected fail-loud (a 400 from
the create path), never silently defaulted. --harness and POST /api/sessions { harness } are not
create-session inputs; callers use --launcher <name> / { launcher }. CLI parsing rejects every unknown
flag with the ordinary usage error, and the create API rejects every unknown body field with the ordinary 400;
unsupported inputs never disappear into a defaulted launch.
Launcher choice belongs only to an explicit creation request: bare spex session new / the New Session
composer uses defaultLauncher, while --launcher <name> / the dashboard picker passes the selected profile
to the same newSession call. @new is ordinary prose, never an alternate creation API.
Persisted and API-exposed, not badged on the board. A session's chosen launcher NAME is durable data: it
is stored on the record and rides the session payload (/api/sessions + /api/graph) alongside its
harness, so any surface that needs the launch identity can read it. It is deliberately NOT rendered as a
per-session board badge — a harness glyph + name on every session row read as visual clutter, so the board
stays clean. The wrong-launcher confusion (a human "testing claude-glm" quietly handed another launcher) is
already closed at the point it matters — the create-time picker honoring defaultLauncher (above) — not by
after-the-fact badging.
Correctness — the RESOLVED command is pinned, not re-resolved (the resume-launcher-pin). The launch
command used to be re-resolved globally at every launch (env → config → default), so a session created under
an API-key launcher would silently become a login session on resume the moment the backend's env or default
differed. Storing the launcher NAME alone did not fully close this: even a named launcher whose cmd config
later changed would resume under the NEW command. This is not cosmetic — the launcher command carries the
agent's config-dir env (claude's CLAUDE_CONFIG_DIR, codex's CODEX_HOME), and that dir is where the
conversation transcript lives. A drifted launcher sends --resume at the WRONG config dir and the conversation
is simply not found ("No conversation found") — the failure that, under a backend restart onto a different
default launcher, silently broke every resume in the mass-restore incident (victims' launch.sh rewritten to a
different launcher while their transcripts lived under the original's config dir).
So the launch owner PINS the resolved base launcher command on the record at creation ([[sessions-core]]'s
launchCmd field, resolved via the [[harness-adapter]]'s baseCmd), and EVERY launch — first launch, drain,
and reopen/relaunch alike — replays THAT exact command. The launcher's resolved cmd is frozen at birth, so
the session resumes under the identical launcher (and identical config dir) for
its whole life, immune to any later change of the default or of the launcher's own config. The launcher NAME is
still stored (for display and as the pre-pin fallback); a record with neither a pinned command nor a name (a
truly old session) falls back to the current ambient resolution, so nothing pre-dating this changes behavior.
The pinned command reaches the agent through
launchCmd, which builds its invocation ON TOP of this base — the ONE seam where a session's frozen launcher
identity overrides the ambient default.