Skip to content

fix(server): default the runtime home to ~/.pylon-code everywhere - #197

Merged
rynfar merged 10 commits into
pylonfrom
fix/runtime-home-default
Aug 31, 2026
Merged

fix(server): default the runtime home to ~/.pylon-code everywhere#197
rynfar merged 10 commits into
pylonfrom
fix/runtime-home-default

Conversation

@rynfar

@rynfar rynfar commented Aug 31, 2026

Copy link
Copy Markdown
Collaborator

Problem

The desktop app has kept Pylon's state under ~/.pylon-code since the fork, but two other launch paths still defaulted to ~/.t3: the t3 CLI (resolveBaseDir in apps/server/src/os-jank.ts) when neither --base-dir nor T3CODE_HOME is set, and the desktop's SSH remote launcher, which hard-coded --base-dir "$HOME/.t3" for the server it starts and kept its own state under ~/.t3/ssh-launch. AGENTS.md names ~/.t3/userdata as T3 Code's database — with incompatible migration numbering — and forbids opening it from a Pylon server, so a bare npx t3 on a machine that also runs T3 Code did exactly the forbidden thing. PR #187's user doc was the first to depend on the default and had to hedge around it.

Fix

Every place a Pylon server or its data is opened by default now uses ~/.pylon-code. T3CODE_HOME and --base-dir are unchanged (compatibility identifiers). There is deliberately no fallback to ~/.t3, because it may be T3's data.

  • CLI. resolveBaseDir defaults to ~/.pylon-code. When the default is used, <default>/userdata is absent, and ~/.t3/userdata exists, the server writes one hint line to stderr telling the user to pass --base-dir ~/.t3 (or T3CODE_HOME) or move ~/.t3/userdata into ~/.pylon-code/userdata, which brings settings and secrets with it. It never fires when a base dir was given, including the desktop's bootstrap, and never for worktree dev runs. Review caught that the first version went through the Effect logger to stdout ahead of --json output; it now bypasses the logger and a test captures the JSON stream to prove it stays clean.
  • SSH launcher. Remote servers start under $HOME/.pylon-code with state in $HOME/.pylon-code/ssh-launch/…. On first launch after the upgrade, a best-effort legacy block finds the old ~/.t3/ssh-launch/<key> state, stops the old launcher-managed server only if ps confirms the recorded pid is a serve --host 127.0.0.1 … --base-dir $HOME/.t3 process (a reused pid is spared), renames the old state dir to .migrated, and prints the same hint to stderr. Every line in the block ends in || true, and the test walks the generated scripts requiring that; sh -n passes on all five scripts.
  • Maintainer scripts. migrate-dev-db.ts seeds from ~/.pylon-code/userdata/state.sqlite and refuses to rebuild either ~/.t3 or ~/.pylon-code (review caught that moving the shared-home constant had dropped the ~/.t3 refusal — the guard is now a separate list and runs before the source-exists check). t3-sqlite-state.ts refuses exec against both homes, canonical-path compared.
  • Docs. remote-access.md, keybindings.md, environment-theme.md, observability.md, internals/scripts.md, the test-pylon-app skill, and AGENTS.md's runtime-home glossary line. install.md gains a WSL paragraph: the in-distro backend takes the new default too, and because ~/.pylon-code/wsl-runtime already exists there, the migration is mv ~/.t3/userdata ~/.pylon-code/userdata, guarded on the destination being absent. That command was run against three sandboxes (migration, populated destination refused, nothing to move).

What happens to existing installs

  • Desktop: nothing changes.
  • CLI-started servers and SSH remotes: start empty under ~/.pylon-code and print the hint; old data stays in ~/.t3 until moved. On a remote host whose ps cannot report process arguments, the old server is left running (safe direction) while its state dir is retired; stop it by hand.
  • WSL backend: same as CLI, per the new install.md paragraph.

Verification

  • 18 files / 215 tests on the focused set (os-jank, ssh tunnel, all cli/, bin, dev-runner, both maintainer scripts); t3, @t3tools/ssh, and @t3tools/scripts typechecks clean; lint, format, git diff --check clean.
  • Six mutation checks across three review rounds: reverting the DEFAULT_SERVER_HOME fails the SSH assertion; restoring the single-home guard fails the refusal test; reverting the guard hoist fails the ordering test; the --json test fails on the pre-fix logger route.
  • Legacy SSH block exercised in a sandbox with a real pid: matching stale server stopped, unrelated sleep holding the pid spared, directory retired, idempotent on re-run, exit 0 on a clean host.
  • Three independent review passes (full review, two scoped re-reviews).
  • Not verified: a real SSH host, a real WSL distro, and a live desktop session.

Follow-up once #180 lands: append the durable-facts record for this decision (drafted in the review report, not committed here because the file only exists on that branch).


Claude Fable 5 in Pylon.


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

rynfar added 10 commits August 30, 2026 22:24
The CLI defaulted its base directory to `~/.t3`, which on most machines is
T3 Code's install. Its database carries upstream's migration numbering, so a
Pylon server that opened it recorded those ids as applied and then died on the
first query. `resolveBaseDir` now defaults to `~/.pylon-code`, matching the
desktop, and the dev runner's implicit home moves with it.

There is deliberately no fallback: adopting another product's database is the
hazard being removed. Instead, when a launch resolves the default base dir,
finds no state there, and sees an older `~/.t3/userdata`, it logs one line
naming that directory and how to keep it — `--base-dir` or `T3CODE_HOME`, both
unchanged. It is silent when the user said where their state lives, for dev
runs, and on every launch after the first, which creates `userdata` itself.

Model: Claude Opus 5. Harness: Claude Code.
The desktop's SSH launcher hard-coded `$HOME/.t3` in the scripts it writes to
the remote host: the launcher state directory, the `--base-dir` it passes to
`serve`, the runtime file it probes to adopt an already-running server, and the
base dir it pairs against. That pointed remote Pylon servers at T3 Code's
install. All four move to `$HOME/.pylon-code`, and no generated script names
`.t3` at all now.

A host that already ran the old launcher keeps its data in `~/.t3`. The new
launcher never reads the old state directory, so it starts a fresh server under
`~/.pylon-code` on a newly picked port and pairs against that; the old
launcher-managed server is not stopped by the new scripts and stays running
until the host restarts or the user stops it. To keep the previous data, point
the remote server at `~/.t3` yourself with `--base-dir` or `T3CODE_HOME`.

Model: Claude Opus 5. Harness: Claude Code.
Moving the SSH launcher to `~/.pylon-code` left hosts that the old launcher had
already set up running a server from `~/.t3` that nothing could reach: the new
state dir cannot see it and the new stop script no longer points at it, so it
sat on its port until the host restarted.

The launch script now retires the old state dir once, before it reads any
current state. It stops the server only when the old `managed` file says the
launcher owned it — the same check `REMOTE_STOP_SCRIPT` makes, so a server the
user started themselves is left alone — then clears the pid/port/managed files
and renames the directory to `<dir>.migrated`, which keeps the old server.log
and stops the block matching on the next launch. Every step is best effort; a
host that will not let us clean up is still a host we launch on. One line goes
to stderr, not the stdout the desktop parses as JSON, saying earlier state may
remain under `~/.t3` and can be moved to `~/.pylon-code` to keep it.

The "no `.t3` anywhere" assertion is replaced by one that checks what a script
points a server at: every `STATE_DIR`, `DEFAULT_SERVER_HOME`,
`DEFAULT_RUNTIME_FILE`, `PAIRING_BASE_DIR`, and `--base-dir` line must name
`~/.pylon-code`. The cleanup block is deliberately absent from the stop,
log-tail, and pairing scripts.

Model: Claude Opus 5. Harness: Claude Code.
Two scripts under `apps/server/scripts` still assumed `~/.t3` was Pylon's, and
the runtime home move turned both into hazards AGENTS.md already names.

`migrate-dev-db.ts` defaulted its *source* database to
`~/.t3/userdata/state.sqlite`, so seeding a worktree's dev database copied T3
Code's schema — carrying upstream's migration numbering — into a Pylon server
that then dies on its first query. It now reads `~/.pylon-code` by default.

`t3-sqlite-state.ts` keyed its "refuse to exec against the shared home" guard to
`~/.t3`, so after the move it no longer protected Pylon's live install. It now
refuses both homes: `~/.pylon-code` because it is Pylon's, and `~/.t3` because
it may be someone else's. Reads are unaffected.

Both take an injected `homeDir` so the new tests can prove the defaults without
reading the developer's own home.

Model: Claude Opus 5. Harness: Claude Code.
`sharedHome` was doing two jobs: it named the home the default `--source` is
read from, and it was the sole entry in the destructive-write guard. Pointing it
at `~/.pylon-code` moved both, which quietly removed the only thing refusing
`--base-dir ~/.t3` — so the script would happily run removeDatabaseFiles over
`~/.t3/userdata/state.sqlite` and its `-wal`/`-shm` siblings. That is a live T3
Code install on most machines.

The two roles are now separate values. `sourceHome` decides where a default
`--source` is read from and stays `~/.pylon-code`. `protectedHomes` decides
which directories `--base-dir` may not name and lists both runtime homes,
compared canonically so a symlink cannot slip past, exactly as
`t3-sqlite-state.ts` does.

Also refreshes four places still describing `~/.t3` as the source — the file
header, the `--source` JSDoc and flag description, the command description —
and the node:os diagnostics comment, and puts Pylon's name in
`t3-sqlite-state.ts`'s missing-database error.

Model: Claude Opus 5. Harness: Claude Code.
`auth`, `connect`, and `project` resolve their base dir through
`resolveServerConfig`, so the hint fires on their `--json` paths too — and it
fires before `runWithEnvironmentAuth` raises the log level for `quietLogs`, so
that guard cannot suppress it. Going through `Effect.logInfo` put a sentence on
the same stream as the JSON payload, ahead of it, breaking any parser reading
`t3 auth pairing list --json` on a first run with `~/.t3/userdata` present.

The hint now writes straight to stderr, like the SSH launcher's does, and is
renamed `warnAboutLegacyRuntimeHome` so it is not casually routed back through
the logger. The sink is injectable so tests never touch the real streams.

The new test runs the real `auth pairing list --json` command against a
throwaway HOME that meets the hint's conditions, captures the payload stream
through TestConsole, and asserts the hint reached stderr while the payload is
still parseable JSON with no trace of it. Both halves fail independently if the
hint goes back through the logger.

Model: Claude Opus 5. Harness: Claude Code.
The legacy cleanup validated the pid from the old launcher's state file with
`kill -0` alone. A pid file outlives the process it names, and after a remote
reboot that number is very likely to have been handed to something else the
same user owns — which the block would then kill.

`legacy_pid_is_stale_launcher_server` now reads the process arguments and
requires both markers the old launcher's own invocation always carried,
`serve --host 127.0.0.1` and `--base-dir $LEGACY_SERVER_HOME`, before any
signal. No match, or no ps output, means the process is left alone; the
directory is still retired either way.

Also adds the `|| true` the hint printf was missing. It was the one step in a
best-effort block that could abort the launch under `set -e`.

Model: Claude Opus 5. Harness: Claude Code.
The WSL backend deliberately omits the Windows-side base dir so it uses its own
home inside the distro, which means the runtime home default moved there too.
An existing WSL install's projects and history stay behind in the distro's
`~/.t3` and the backend starts empty at `~/.pylon-code`.

Documents that third class of install alongside the WSL runtime notes, with the
one command that carries the old state forward and the conditions for running
it safely.

Model: Claude Opus 5. Harness: Claude Code.
The migration command told people to move `~/.t3` onto `~/.pylon-code` and to
only do it when `~/.pylon-code` did not exist. That precondition excludes
exactly the readers the paragraph is for: the runtime installer creates
`$HOME/.pylon-code/wsl-runtime` inside the distro, so the directory is already
there for anyone who has launched a WSL backend once. Following the command
anyway would have nested the old tree at `~/.pylon-code/.t3`, which nothing
reads.

It now moves `userdata` — the state itself — and keys off `~/.pylon-code/userdata`
being absent, the same condition the in-product hint uses. Settings and secrets
live inside `userdata` and come along; `caches` and `worktrees` sit beside it
and deliberately stay, the latter because projects reference those paths
absolutely.

The hint text follows the same correction: it named the base directories where
it meant the state directories, so it read as "move that directory to
~/.pylon-code" when the parent is not what should move.

Model: Claude Opus 5. Harness: Claude Code.
`migrate-dev-db --base-dir ~/.t3` reported "Source database does not exist"
whenever `~/.pylon-code` was not populated yet, because the source check ran
first. Nothing destructive happened either way, but it answered a question the
user had not asked and hid the one that mattered.

The protected-home guard now runs ahead of it, so aiming at a live install is
reported as exactly that. The covering test no longer creates a source at all,
which is what pins the ordering.

Model: Claude Opus 5. Harness: Claude Code.
@github-actions github-actions Bot added vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. size:L labels Aug 31, 2026
@github-actions

Copy link
Copy Markdown

Thread transfer impact

✅ Thread transfer remains within every enforced ceiling.

Provider Metric Main baseline This PR Impact PR ceiling
Codex Total thread wire 13.7 KiB 13.6 KiB −178 B (−1.3%) 15.1 KiB
Codex Thread snapshot wire 6.9 KiB 6.9 KiB +4 B (+0.1%) 7.3 KiB
Codex Live turn WebSocket wire 6.8 KiB 6.6 KiB −182 B (−2.6%) 7.8 KiB
Codex Live turn WebSocket decoded 58.7 KiB 57.2 KiB −1.5 KiB (−2.5%) 66.4 KiB
Codex Live turn messages 11 10 −1 (−9.1%) 21
Claude Total thread wire 13.5 KiB 13.6 KiB +41 B (+0.3%) 15.1 KiB
Claude Thread snapshot wire 6.9 KiB 6.9 KiB −1 B (−0.0%) 7.3 KiB
Claude Live turn WebSocket wire 6.6 KiB 6.6 KiB +42 B (+0.6%) 7.8 KiB
Claude Live turn WebSocket decoded 58.0 KiB 58.1 KiB +44 B (+0.1%) 66.4 KiB
Claude Live turn messages 9 10 +1 (+11.1%) 21

Baseline: 721713e · PR result: 66401f0 · Source CI: success

Scenario and decoded snapshot size

10 historical turns, 5 command tools per turn, 878.9 KiB retained MCP result per historical turn, and a 1.05 MiB retained result in the measured turn.

  • Codex decoded thread snapshot: 109.5 KiB
  • Claude decoded thread snapshot: 110.2 KiB

Updated in place by a trusted workflow. PR artifacts are strictly validated and never executed.

@rynfar
rynfar merged commit 96639fe into pylon Aug 31, 2026
14 checks passed
@rynfar
rynfar deleted the fix/runtime-home-default branch August 31, 2026 04:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:L vouch:trusted PR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant