Skip to content

feat(orchestrator): Python-canonical launchers (replaces PR#3 bash port)#5

Open
evannadeau wants to merge 18 commits into
SpawnBox-dev:mainfrom
evannadeau:feat/orchestrator-launcher-python-canonical
Open

feat(orchestrator): Python-canonical launchers (replaces PR#3 bash port)#5
evannadeau wants to merge 18 commits into
SpawnBox-dev:mainfrom
evannadeau:feat/orchestrator-launcher-python-canonical

Conversation

@evannadeau
Copy link
Copy Markdown

Why

Per upstream operator decision (sequenced after PR#4 — backup-plugin-db
skill — though that PR remains unmerged at time of opening), this PR
retires the cross-language drift between the canonical PowerShell
launchers and the closed-PR#3 bash port by consolidating both
implementations into a single Python-canonical surface.

Python is already a baseline dependency of the orchestrator plugin via
sidecar/embed_server.py and sidecar/requirements.txt, so this PR
adds no new runtime dependency.

What changes

Removed (replaced in place):

  • ~496 LOC of canonical PowerShell logic across the three .ps1
    launchers.

Added:

  • _launcher_common.py — shared module (project-dir resolution,
    CC project-hash transform, sessions.json singleton-supersede,
    env-var assembly, claude argv builder, terminal-spawn abstraction).
  • pa_start.py / sa_start.py / discord_start.py — three entry
    points (one each per launcher kind).
  • pa-start.sh / sa-start.sh / discord-start.sh — thin POSIX
    wrappers that locate `python3` (honoring `$ORCH_PYTHON`) and exec
    the entry .py.
  • pa-start.ps1 / sa-start.ps1 / discord-start.ps1 — thin
    Windows PS wrappers that locate `python.exe` / `py.exe` with
    Microsoft Store stub detection (per the published preflight rule).
  • tests/launchers/ — pytest suite for the shared module
    (project-hash, resume-resolve, supersede, session-name, setup-env,
    build-args, launch) + smoke tests for each entry point via the new
    `--dry-run` mode.
  • pyproject.toml — Python 3.10+ floor, pytest as dev-only
    dependency.
  • One-line addition to package.json:
    `"test:py": "uvx --from pytest pytest tests/launchers/"`.

Unchanged:

  • The three .bat double-click trampolines retain their shape (they
    dispatch to the same-name .ps1, which is unchanged in name only).

Behavior

User-observable behavior is preserved: same CLI flags, same env vars
set on the spawned `claude` process, same sessions.json mutations,
same wt.exe tab colors. One tightening: `sessions.json` write failure
during the supersede block is now FATAL (the .ps1 treated it as
warn-only, which could silently leave two `role=prime` entries and
break the singleton invariant).

Discord-start was omitted from the closed bash port (PR#3) — this PR
ships it.

Test plan

Locally (CI surface):

```bash
cd plugins/orchestrator
bun install
bun run typecheck # unchanged — no .ts touched
bun test # unchanged — no .ts touched
uvx --from pytest pytest tests/launchers/ # new — 45 tests, all green
```

Operator-driven E2E on real systems (per the bash-port verification
protocol from the closed PR#3):

  • WSL/Ubuntu with `python3` already present.
  • Windows with python.org install.
  • Windows with the real Microsoft Store Python 3.x (the legitimate
    distribution, distinct from the App Execution Alias stub).
  • Optional: Windows without Python installed (verifies wrapper's
    "install Python" error message is actionable).

Stack independence

This PR is independent of:

  • PR#2 (sidecar boot timeout + stderr capture) — touches different
    files in `mcp/server.ts`.
  • PR#4 (backup-plugin-db skill) — touches a different skill directory.

Cleanly mergeable in any order.

Design + Plan

Both committed alongside the code:

  • `docs/plans/2026-05-13-launcher-python-canonical-design.md` (350 lines)
  • `docs/plans/2026-05-13-launcher-python-canonical-plan.md` (3309 lines)

evannadeau added 18 commits May 13, 2026 14:53
Design document for the pa_start / sa_start / discord_start
Python-canonical launcher refactor, replacing the existing
PowerShell-canonical trio with thin .sh / .ps1 / .bat wrappers.

Implements upstream decision to retire PR#3 (the closed bash port)
in favor of a single canonical Python implementation. Python is
already a baseline dep of this plugin via sidecar/embed_server.py,
so the rewrite adds no new runtime dependency.

Captures:
- File layout (13 files: 1 shared module, 3 entry points, 9 wrappers)
- Shared-module interface (_launcher_common.py)
- Per-entry-point data flow + distinctness table
- --dry-run mode (debug + smoke-test surface)
- Wrapper templates (POSIX + Windows with MS Store stub detection)
- Error-handling table with exit codes
- Test layout (pytest under tests/launchers/, ~8 test files)
- install-launchers/SKILL.md changes required
- Migration notes for existing local bash-port users
- Out-of-scope items (macOS osascript, Python-driven installer,
  sidecar discovery convergence)

No code changes in this commit — spec only. Implementation follows
in a separate commit on the same branch once the plan is written.
… rewrite

Task-by-task TDD plan for executing the design committed in 2f1da2d.
17 tasks across the shared module, three entry points, six platform
wrappers, install-skill update, and final integration check.

Each task has 5-7 steps following red-green-refactor-commit:
  1. Write the failing test (concrete code).
  2. Run the test, expected: fail.
  3. Implement (concrete code).
  4. Run the test, expected: pass.
  5. Commit.

All file paths absolute, all commands runnable, no placeholders. Plan
self-reviewed against the design; type signatures cross-checked across
referencing tasks.
Adds pyproject.toml (Python 3.10+ floor, pytest dev-only), a minimal
conftest.py with project-dir / sessions-file / env-snapshot / fake-
projects-dir fixtures, and a 'test:py' package.json script that runs
pytest via uvx (matching the sidecar's existing uvx fallback pattern,
so contributors don't need a separate pip install).

No production code yet — empty harness baseline for the launcher
rewrite (see docs/plans/2026-05-13-launcher-python-canonical-design.md).
… placeholder

Adds the shared launcher module skeleton with two foundational pieces:

1. sys.version_info check at import time. Floor is Python 3.10
   (justified by PEP 604 type-union syntax used elsewhere in the
   module and broad ecosystem availability). Wrappers also catch
   missing-Python at the shell level; this catches the case where
   the wrapper finds a too-old interpreter.

2. MARKETPLACE_PLACEHOLDER constant + check_marketplace_substituted()
   guard. The /orchestrator:install-launchers skill substitutes the
   placeholder at copy-into-project time. If the guard runs against
   an unsubstituted module, it exits with an actionable error
   pointing back at the install skill.

Split-string comparison in the guard avoids self-detection by the
substitution tool.
Adds project_hash_for(project_dir: PurePath) -> str, mirroring the
literal character-substitution transform that Claude Code applies to
project paths when naming ~/.claude/projects/<hash>/ directories:

  - backslash, forward slash, colon → single dash
  - consecutive dashes NOT collapsed (C:\ yields C--)
  - leading and trailing dashes stripped

Test coverage: POSIX simple + deep, Windows with drive letter
(verifies the C-- prefix is preserved), leading-dash strip, no
trailing dash. Five cases, all pass.
Adds resolve_project_dir(arg: str | None) -> Path. Handles:
  - None / empty arg → CWD
  - Relative path → resolved against CWD
  - Absolute path → passes through
Always returns absolute. Exits 1 if the resolved path doesn't exist
(matches the .ps1 launchers' Resolve-Path + Test-Path behavior).

Test coverage: default-to-CWD, explicit absolute arg, relative arg
resolved against CWD, missing-dir exits 1.
Adds resolve_resume_target(resume: str, project_dir: Path) -> str.
Mirrors the .ps1 launchers' display-name → UUID lookup:

  - canonical UUID shape passes through unchanged
  - otherwise, grep ~/.claude/projects/<hash>/*.jsonl for the literal
    'Session renamed to: <name>' marker
  - newest match wins (by mtime)
  - missing projects-dir or no-match exits 1

Tests cover: UUID passthrough, single match, newest-wins on duplicates,
no-match exit, missing-projects-dir exit. Five cases.
Adds the PA singleton-supersede helper. Mirrors the .ps1 launchers'
pre-launch demote block:

  - no sessions.json → no-op
  - corrupt JSON → WARNING (treated as no-PA, self-heals)
  - any role=prime entries with last_heartbeat_at > now-90s → demoted
    to role=subordinate, file rewritten
  - stale primes (heartbeat older than 90s) left alone
  - 2-second pause after demote (matches .ps1 'press Ctrl+C to cancel')

Tightens one .ps1 behavior: write errors are FATAL (exit 1) instead
of warn-only. Silent write failure would leave two role=prime entries,
breaking the singleton invariant.

Tests cover all 6 paths: no-state-file, no-primes, stale-prime,
fresh-prime, parse-failure (warning), write-failure (fatal).
Adds make_session_name(prefix: str) -> str. Returns
'<PREFIX>-YYYY-MM-DD-HH-MM-SS' using local time (matches the .ps1
launchers' Get-Date format).

Tests verify regex shape for PA, SA, and DISCORD-LIVE prefixes.
Adds setup_env(*, role, session_kind, project_dir, session_name). Sets
the same env vars the .ps1 launchers set on the spawned claude.exe:
MCP_TIMEOUT, ORCHESTRATOR_PROJECT_ROOT, role + kind + name (each with
the SPAWNBOX_ alias for back-compat), and the PA permission-relay flag.

When session_name is None (--resume without --name), the NAME envs are
left unset so the resumed session's existing /rename name is preserved.

Tests cover PA, SA, Discord-bot, and no-session-name paths.
Adds build_claude_args(*, marketplace, session_name, resume, effort,
extra_channels) -> list[str]. Constructs the argv passed to the spawned
'claude' process. Mirrors the .ps1 launchers' claudeArgs assembly:

  - extra_channels prepended (Discord-only)
  - --dangerously-load-development-channels plugin:orchestrator@<mkt>
  - --effort <level> if set (PA: hardcoded 'max'; SA: optional CLI;
    Discord: never)
  - --name <session_name> if set (omitted on --resume without --name)
  - --resume <uuid> if set

Tests cover: PA minimal shape, SA --resume, Discord with both channel
flags in correct order, marketplace-slug verbatim interpolation.
…tion)

Adds launch(claude_args, *, project_dir, tab_color, no_wt) -> int.
The only place in _launcher_common.py that branches on platform.

  - shutil.which('claude') gate: missing → exit 127 with install hint
  - POSIX: os.execvp('claude', ...) — replaces Python process
  - Windows + wt.exe + not no_wt: wt.exe -w new new-tab [--tabColor X]
    -d <dir> claude <argv>
  - Windows otherwise: subprocess.run([claude, ...])

Tests use shutil.which / platform.system / os.execvp / subprocess.run
monkeypatching to assert on the resolved spawn command without
actually exec'ing claude or wt.exe.

This completes the _launcher_common.py shared module. Full suite:
38/38 passing.
PA launcher entry point. Composes _launcher_common helpers:

  - check_marketplace_substituted() (fail-fast guard)
  - resolve_project_dir → resolve_resume_target (if --resume)
  - supersede_existing_pa (singleton enforcement; skipped on --dry-run)
  - make_session_name('PA') unless resuming
  - setup_env(role='prime', session_kind='prime', ...)
  - build_claude_args(effort='max', ...)
  - launch(tab_color='#F59E0B', ...) OR --dry-run JSON output

--dry-run mode performs all state mutations EXCEPT the sessions.json
write and launch() call, then prints a JSON envelope describing the
resolved argv + env_overrides + tab_color + use_wt. Used by the smoke
tests and as a user-facing debug tool.

Two smoke tests: dry-run output shape + PA-prefixed session name.
SA launcher entry point. Same composition as pa_start.py with the
SA-specific deltas:

  - role='subordinate', session_kind='subordinate'
  - no singleton-supersede (subordinates are not singleton)
  - --effort is an optional CLI flag (validated against
    low/medium/high/xhigh/max); omitted from claude argv when not set
  - --name is an optional CLI flag (overrides auto-generated SA- name)
  - tab_color=None (no special tab color for SAs)

Tests cover: no --effort, --effort max, --name override.
Discord-ops launcher entry point. SA-shaped composition with the
Discord-specific deltas:

  - role='subordinate', session_kind='discord-bot' (gates classifier
    policies + discord-bootstrap skill identity check)
  - session name always auto-generated DISCORD-LIVE-<timestamp>
    (no --name flag, no --resume flag)
  - extra --channels plugin:discord@claude-plugins-official IN
    ADDITION to the orchestrator dev-channels flag
  - tab_color='#DC2626' (red)

This completes the three Python entry points. Full suite: 45/45.
Three thin Bash wrappers (~17 LOC each) that:
  - locate a Python interpreter (\$ORCH_PYTHON > python3)
  - emit an actionable 'Python not found' error with install hints if
    none available
  - exec the corresponding pa_start / sa_start / discord_start .py
    with passthrough args

Mode 755 — git tracks the executable bit.

Smoke-tested locally: each wrapper invokes its .py and emits a valid
--dry-run JSON envelope when the marketplace slug is substituted.
PA emits gold tab + --effort max + role=prime; SA emits no tab color
and respects --effort; Discord emits red tab + dual --channels +
session_kind=discord-bot.
Replaces the three canonical PowerShell launcher scripts (208 + 175 +
113 = 496 LOC of business logic) with thin wrappers (~30-33 LOC each
identical-pattern) that:

  - try \$env:ORCH_PYTHON, then python.exe, then py.exe
  - detect the Microsoft Store App-Execution-Alias stub by output
    match ('Python was not found') per anti-pattern adf2b104
  - check \$LASTEXITCODE after every native-exe call (PowerShell
    try/catch does NOT catch native-exe failure)
  - emit actionable install hints (winget / python.org / MS Store
    Python real / \$ORCH_PYTHON) if no interpreter resolves
  - exec the corresponding pa_start / sa_start / discord_start .py
    with PowerShell splat (@Args) for full argument passthrough

.bat trampolines unchanged — they dispatch to the .ps1 by filename,
which is unchanged in name (only the .ps1 body differs).

Also adds __pycache__/ and .pytest_cache/ to plugins/orchestrator/
.gitignore so Python test runs don't dirty the worktree.
…chers

Updates the install skill to reflect the 13-file layout (1 shared
Python module + 3 entry points + 3 .sh + 3 .ps1 + 3 .bat). Substantive
edits:

  - Overview: 'ships canonical PowerShell launchers' → 'ships canonical
    Python launchers (with thin platform wrappers)'.
  - File count: 'SIX files' → 'THIRTEEN files' with breakdown.
  - Launcher inventory table: gains a 'Files installed' column
    enumerating the 4 files per launcher kind.
  - New 'Prerequisites' section calling out Python 3.10+ as a hard
    requirement (with install hints per platform) and noting that
    Python is already a baseline plugin dep via the sidecar.
  - Step 4 (copy): enumerates all 13 filenames + chmod 755 on the
    three .sh wrappers (no-op on Windows).
  - Step 5 (substitute): single sed/PowerShell replace against
    _launcher_common.py instead of the previous loop over .ps1 files.
    New marketplace guard verification (python3 -c ... import
    _launcher_common; check_marketplace_substituted()).
  - Step 6 (verify): updated grep targets to _launcher_common.py.
  - Step 7 (usage): split into POSIX + Windows sections, documents
    --dry-run and $ORCH_PYTHON overrides.
  - Quick reference table updated for 7 steps + correct file counts.
  - Common mistakes: added Python interpreter mismatch (MS Store stub)
    and chmod-the-.sh entries.
  - Notes: --dry-run debug tip added.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant