Skip to content

Fix shell environment inherited by Pi sessions - #21

Closed
JnBrymn wants to merge 1 commit into
gregce:mainfrom
JnBrymn:fix/issue-20-shell-environment
Closed

JnBrymn wants to merge 1 commit into
gregce:mainfrom
JnBrymn:fix/issue-20-shell-environment

Conversation

@JnBrymn

@JnBrymn JnBrymn commented Sep 14, 2026

Copy link
Copy Markdown

Closes #20.

Pi was started inside a Tortie-managed shell session whose environment did not reliably match a normal terminal. In particular, shell startup files exporting Pi provider keys could be skipped, leaving Pi with only cached/Codex models; Tortie also retained literal ~ components in the captured PATH.

This changes shell sessions to interactive login shells so the normal shell environment is loaded, expands ~ PATH entries during capture, and keeps restart handling compatible with existing -l sessions.

Not tested: Vitest is unavailable in this checkout. I do not know whether this fixes the issue.

gregce added a commit that referenced this pull request Sep 14, 2026
Issue 20: the session environment, researched

NOTHING WAS REPAIRED. This adds one document under docs/research/ and changes
no file under src/. No Electron was launched, PR #21 was read and never merged,
commented on or closed, and no variable's value appears anywhere in the document.

The report is two complaints with different answers.

The title's premise is false, and it is false in the direction of good news. A
session is already not a subprocess of Tortie. Measured on his own machine: a
pane's parent is the private tmux server and the server's parent is launchd,
with the Tortie that ran new-session nowhere in the ancestry. That is the
durability invariant working as designed, and it is why quitting Tortie does not
end his sessions. What he is actually feeling is the long-lived server's frozen
boot environment, which a Tortie restart does not refresh because a Tortie
restart does not restart the server. The live server's global environment holds
17 names, all launchd-shaped, and its PATH is 35 entries with no tilde and
nothing relative.

The second half is real, and it is Phase 33's problem statement word for word.
launch.envPassthrough shipped, works, resolves values per launch and writes none
of them to the manifest, and is set by no compiled agent row. So a provider key
exported in ~/.zshrc reaches no freshly created agent pane on any machine until
the person writes an agents.json, restates launch.argv and passes the confirm
gate. The mechanism exists and is inert everywhere.

The real finding is the third thing, and it is ours rather than his. When an
agent pane starts without the environment it needs, Tortie says nothing at all.
The env-unresolved notice is structurally unable to fire for him, because both
call sites are guarded on the row already naming variables; Settings draws
nothing when there is no config file, which hides the only sentence that names
agents.json; and agents.json appears in exactly one renderer string in the whole
app, inside that hidden block. Phase 33 built the mechanism and shipped no path
to it, so he got pi's own model error and silence from us.

PR #21: decline changes 1 and 2, adapt change 3 as its own entry. The login
shell flag is gated to agent === 'shell' at both call sites, so -il never
reaches pi; a shell pane is already interactive, measured; -i is a regression on
the nine harness zsh -l -c sites, 68 ms to 1714 ms with a banner prepended to
stdout; and four tests go red, one of them substantive, under which an existing
shell session silently comes back running a different command. Expanding ~
inside captured PATH entries is the one genuinely new thing in the diff and
nothing in the tree does it today, but it is unrelated to the reported symptom
and produces command-not-found rather than a model error.

Two drifts are reported and left alone: the probe deadline is 10 seconds while
the shipped guide, the Phase 33 backlog entry and a code comment all say 3, and
research 41 section 11 has three open items this document closes.

No phase is queued off this. That waits for his word.
gregce added a commit that referenced this pull request Sep 14, 2026
One running-log line, appended at the end, newest last. Records that issue 20
was researched to a document and that no source file moved: the title's premise
is false because a session's parent is already the private tmux server rather
than Tortie, the staleness is the server's frozen boot environment, the second
half is Phase 33's problem statement with the shipped mechanism set by no
compiled row, the real finding is that Tortie says nothing when a pane starts
without its environment, and PR #21 declines two changes and adapts the third.

No phase is queued off this. That waits for his word.
gregce added a commit that referenced this pull request Sep 17, 2026
Phase 276: ask the shell once

Phase 275 gave a person one shared list of shell variable names for every
agent, and sent them a bill for it. A shared name applies to EVERY agent, so
the login-shell probe that used to fire for the one agent somebody had
configured now fires for all of them, on every create. This commit is the
second half of that phase's own bill.

THE COST IS ONE FLAG AND NOT THE SHELL, and the measurement is the whole
argument. On the operator's machine, three runs each doing identical trivial
work, `zsh -lic` read 1160, 970 and 980 ms while `zsh -lc` read 10, 10 and
10 ms. A hundredfold, and all of it is the `i`: his `~/.zshrc` sources
oh-my-zsh, nvm and rbenv, and those load for an interactive shell alone.
Re-derived once more at commit time on a calibrated slow home whose .zshrc
sleeps 900 ms, by a different method from anything the phase instruments,
`zsh -lic` read 960, 940 and 940 ms and `zsh -lc` read under 10 ms three
times — and `-lc` did NOT see the .zshrc export while it did see the
.zprofile one.

THE `i` IS LOAD-BEARING AND IT STAYS. `.zshrc` is read by interactive shells
and by nothing else, and a provider key exported there is the whole of issue
20. Dropping the flag makes the probe fast and the feature useless, which is
the mistake PR #21 made. So the second is not removed. It is paid ONCE.

THE HEADLINE, MEASURED AT THE PARENT AND AT HEAD BY THE SAME PROBE

`probe:p276` launches one Electron on a scratch profile with a scratch HOME
whose .zshrc sleeps 900 ms, and points `$SHELL` at a wrapper named `zsh` that
appends one line per login shell and then execs the real one. So the headline
is counted from OUTSIDE the app rather than from anything the app reports.

At the parent, nineteen creates, every one of them 958 to 1008 ms, and 22
login shells for the run. The six warm creates started 5 login shells and the
create for a second agent started 1.

At HEAD, the cold create is 51 ms and the six warm creates are 25, 23, 20, 19,
20 and 21 ms. A create for a SECOND agent is 18 ms. Sixteen login shells for
the whole run, and ZERO of them for the cold create, ZERO for the six warm
creates, ZERO for the second agent and ZERO for a settings write that is not a
shell variable.

THE KEY IS COVERAGE, AND THAT IS THE LOAD-BEARING HALF

There is one slot in `src/main/tmux/resolve.ts`, beside the PATH memo it is
modelled on. An ask is answered from it if and only if the slot holds every
name the ask mentions, and the answer is projected onto the ask. A name a
person has just added in Settings can therefore never be served out of an
answer that does not mention it — no listener, no event and no timer takes
part in that, it is a property of the read.

Equality would have been the obvious key and it is wrong twice. Nothing at
boot knows which agent a person is about to create, so a warm-up's set would
equal no later ask and every create would miss. And two agents with different
per-agent lists would evict each other. A miss therefore probes for the UNION
of the ask and the slot, which is free: on the calibrated slow home `zsh -lic`
for ONE name read 811, 818, 828, 834 and 838 ms and for FIFTY-TWO names read
918, 853, 850, 854 and 839 ms, because the cost is the shell start and not the
number of printfs. Past 256 names the slot is replaced by the caller's own
names rather than grown, never partially merged and never truncated.

The projection hands out a fresh object every ask and iterates the CALLER's
list in the caller's own order, because `create-local.ts` aliases what it is
handed and because Phase 275 promised the `-e` argv order does not move for a
person who never touches the shared list.

A FAILED PROBE IS NEVER CACHED AS SUCCESS

`captureLoginShellEnv` signals failure by VALUE and not by rejection, so the
naive memo would remember a failure for the life of the process and every
session for the rest of the run would launch with no values — Phase 269's
silent, provider-shaped failure re-created by our own optimisation. A result
with `probeFailed: true` is returned to every waiter and installed nowhere,
the in-flight pointer is cleared so the next ask is a fresh attempt, and
nothing is disarmed, dropped or re-warmed, because a shell that timed out once
under load is not a reason to throw away an answer that is still true. Driven:
an rc that hangs was killed at the 10,004 ms deadline, the slot held nothing
afterwards, and the next create recovered with the right value. A PARTIAL
answer is cached, and the argument is written into the code: a stale miss is
self-announcing, because it produces the `env-unresolved` notice naming the
variable on every create, and a stale hit says nothing at all.

A probe that a drop overtook installs nothing either. That race is one this
phase CREATES rather than inherits — the PATH memo next door has no production
invalidator and its equivalent has never fired — so the generation is stamped
before the probe starts and compared when it settles.

THE WATCH, AND THE fs.watch TABLE IT WAS BUILT FROM

`src/main/env/watch.ts` holds `node:fs`'s own watch, non-recursive, over the
files the person's shell actually reads, derived from `$SHELL` rather than
hardcoded. Measured, one scratch directory, a 1.5 s settle per step: a
rename-over gave the FILE watch 1 event and the DIRECTORY watch 2; the next
edit gave the file watch 0 and the directory watch 1; the edit after that, the
same. So a file watch goes deaf after the rename every editor does. `fs.watch`
on a path that does not exist threw ENOENT, and the directory arm caught the
later creation at +11 ms. With `~/.zshrc` a symlink into a dotfiles repo the
`$HOME` directory watch saw 0 events, because nothing in `$HOME` moved, and
the REALPATH arm saw it. So both arms, deduped by directory and basename.

Non-recursive is the whole answer to cost. Over a home-shaped tree of 2,000
writes under `Library/Caches` and `node_modules` plus ONE edit to `.zshrc`,
`@parcel/watcher` delivered 2,001 events and `fs.watch` delivered 1, and both
caught the edit. On the operator's real home, read only, one non-recursive
`fs.watch` delivered 0 events in 30 seconds and 5 in 120 seconds, none of them
an rc basename. `src/main/watcher/` is refused for a second reason: every
subscription it makes carries the FSEvents exclusion plan, which accepts at
most eight paths and applies ZERO at nine, so a home-directory subscription
inside that budget has silent total failure of every other repository's
exclusions as its overflow mode. `conformance:watcher` runs anyway to prove
the budget is untouched.

THE ROTATION, READ OUT OF THE PANE'S OWN ENVIRONMENT

Eleven save shapes, each one followed by a real session whose stand-in agent
read its OWN environ and wrote down which GENERATION of an invented sentinel
it received. Never a value, and the sentinels are invented names against
invented providers.

  append in place        gen-2   arrived
  truncate and rewrite   gen-3   arrived
  rename-over            gen-4   arrived
  unlink and recreate    gen-5   arrived
  a file created for the first time, watched though it did not exist   gen-6
  a write through a symlinked path                                     gen-6
  an edit to a dotfiles-repo target inside the home                    gen-8
  touch with no change   value unchanged either way
  a file the rc SOURCES  STALE at gen-1, as declared
  then Re-read shell     gen-2   delivered
  a name added in Settings while the app ran                           gen-13

The sourced row is the one the watch provably cannot see: the rc itself never
moved and the sourced file is one directory down from a non-recursive watch,
and it started 0 login shells for the edit. That is why there is a button.

FIVE CONCURRENT CREATES START ONE LOGIN SHELL. The in-flight pointer is
assigned synchronously before any await, so there is no window in which two
callers see an empty one, and two callers sharing a capture each project onto
their OWN list, so one create never sees another agent's names on its `-e`
line or in its notice.

THE REFRESH IS A VISIBLE CONTROL, SO THE SEMVER IS MINOR

The entry said patch unless the refresh became visible, and it became visible.
Settings, Launch defaults, a right-aligned [Re-read shell] in the section
toolbar, drawn only when this person has named a shell variable somewhere. Its
whole explanation is behind hover — "Ask your shell again. The next session you
start gets the current values." — because "Just enough words" puts explanation
a person might want behind hover rather than on the resting face. It says the
NEXT session and never this window, because pressing it reads nothing into the
window: the channel resolves with nothing at all, not a name, not a count and
certainly not a value. A button that returns nothing can never lie. There is
no age line, following SpecStory rather than Agents, because an age climbing
while you watch it turns a control a person almost never needs into a nag.

ONE DEVIATION FROM THE SPEC, AND IT IS IN THE CSS. The spec said this phase
would add no CSS rule. It adds one declaration, `.set-toolbar-end`, because
`.set-section-toolbar` is `display: flex` with no justification and does not
right-align on its own: the Agents section gets that from its age line's
`margin-right: auto`, and the SpecStory section spells the same declaration
locally as `.ss-toolbar`. This is the third instance of the shape and the
second with nothing to its left, so it is written once in `settings.css`
rather than a third time in a third file.

THE WARM-UP IS OFF THE BOOT PATH, MEASURED WITH IT ON AND OFF

Two steps and one chain, a sibling of the Phase 208 login observe rather than
a link in it. Arming is synchronous and happens as soon as the core is open,
because arming early is what buys the restore burst: with the cache armed the
first restored session's probe fills the slot and every session after it hits,
where the parent has twenty restored sessions pay twenty login shells. The
PROBE then waits the same second the login observe waits, and nothing awaits
either step.

Twelve launches, six with the feature on and six with `GMUX_NO_ENV_CACHE=1`.
`window-shown` read 402.6, 425.5, 412.0, 390.7, 419.6 and 424.4 ms with it on
against 408.8, 402.4, 449.2, 405.8, 403.3 and 411.8 ms with it off — medians
419.6 and 408.8 ms, which is inside the spread. A second run of the same arm
read medians 380.8 on and 390.8 off, in the other direction. `path-ready`,
the control that SHOULD move, read 1377.8 to 1458.4 ms on against 1400.6 to
1447.9 ms off. The feature-on arm started 2 login shells per launch against
1, so the warm-up definitely ran.

NO VALUE IS PERSISTED ANYWHERE

The rule is Phase 269's and keeping the answer longer tightens it rather than
loosening it. The probe swept the app's own output for the sentinel values
after the whole run: 72 profile files scanned and 0 hits, 0 in the manifest,
0 in the log, 0 in settings.json, 0 in the pane report and 0 in the harness's
own stdout. `loginShellEnvNamesHeld()` is the only door out of the slot and it
hands back NAMES; there is no exported function, field or channel through
which a value can leave it.

THE CACHE IS OFF UNTIL SOMETHING IS WATCHING IT

`envCacheArmed` starts false, and only the watcher turns it on, after it has
derived a watch set and opened at least one handle. While it is false,
`loginShellEnvFor` IS `captureLoginShellEnv`, byte for byte the parent's
behaviour, down to refusing even the in-flight join. So every way the watcher
can fail to build itself — an unrecognised `$SHELL`, a home no watcher can
open, a `ZDOTDIR` outside the home, a quit that overtook the boot chain — lands
on today's cost rather than on a cache nothing can invalidate. That is a proof
rather than a convention: a stale key needs the cache on, and the cache being
on needs the invalidation armed. And the per-create probe stays as the
FALLBACK on every one of those paths, because a session that starts without a
key a person set is worse than a session that takes a second.

THE REMOTE PROBE DOES NOT CACHE AND THIS PHASE DID NOT TOUCH IT. Its own
header already ruled it. The cost being paid down here is on this Mac, a cache
keyed without the machine would hand one machine's values to another, and the
half that makes the local cache safe does not exist over there, because
nothing can `fs.watch` across ssh. `REMOTE_ENV_ALLOWED` is still exactly two
names and no value is ever sent from this Mac.

WHAT THE VERIFIERS DID THAT THE BUILDERS DID NOT

One wrote its OWN rig against the shipped modules over a real scratch HOME
with a real zsh, and attacked the invalidation with shapes no builder tried:
`sed -i` with its backup-and-rename, a rapid burst of edits, an rc that exits
non-zero, a `$SHELL` that does not exist, an rc that hangs past the deadline,
a rotation landing in the middle of a probe, and a dotfiles target OUTSIDE the
home, which must read stale and did.

Another measured the PARENT commit with the same app probe, which is where the
958-to-1008 ms row above comes from, and the parent arm failed on exactly the
three findings that prove the phase: the warm creates started 5 login shells,
the second agent started 1, and the sourced row invalidated because a parent
with no cache reprobes every time.

A third re-derived the `-i` ratio and the "widening is free" claim with its own
spawner rather than the phase's instrumentation, and the two agreed.

THE GATES

`conformance:shellenv` is new and drives the shipping cache and the shipping
watcher over their own seams — a counting fake for the capture, a fake
directory watcher fired by hand, a fake clock so the 400 ms debounce and the
5,000 ms floor are read as numbers rather than waited out — and then reads the
refusals out of the tree. It spawns no shell, which is the point rather than a
convenience. `ablation:p276` is the attack beside it: 32 ablations, one clause
each, every one red on the rule that owns it, measured as a DELTA against the
base so an inherited failure cannot be mistaken for a caused one.

typecheck, build, test at 913 files and 14,496 tests, smoke:t1 6/6, smoke:t3
3/3, package signed, conformance:shellenv, ablation:p276, conformance:watcher,
conformance:resume:capture, conformance:credentials, and the menu gates inside
build, which prove the native menus did not change. `HELPER_USER_FLOOR` 137 to
138 for the new probe, `RUNNER_CALLER_FLOOR` 48 to 50, and the contract
baseline regenerated for one added read-only channel, `settings:envRefresh`,
233 to 234, and two environment names, `GMUX_ENV_WATCH_SEED` and
`GMUX_NO_ENV_CACHE`, 111 to 113.

ONE THING THE HARNESS NEEDED, AND IT IS THE INTEGRATOR'S FINDING. The boot
chain sits below `dispatchHarness`, which is the right position and does not
move, so a driven app run never reaches it. The first run of `probe:p276` read
ONE login shell and about 960 ms for every one of eighteen creates — the
parent's behaviour exactly — because nothing in a harness launch arms the
cache. `src/main/harness/env-watch-seed.ts` calls the SHIPPED pair, in the
shipped order, behind the same two refusals the other seeds carry, and seeds
no value of its own.

THE LIMITS, PLAINLY

A key exported by a file the rc SOURCES, one a vault hands over at shell start,
one a plugin loads from a `.env`, or one the shell INHERITS does not move
anything we watch, and needs the button. A dotfiles target outside the home
tree is not watched at all, by this phase's own refusal. A person who really
has `ZDOTDIR` set to the empty string reads as unset through the probe, so the
automatic invalidation is blind for them; the cache is still correct, because
coverage and the refresh both work, and only the convenience is lost. `fish` is
not in the shell table, because it is not installed on the machine this was
built on and an unmeasured row would arm a cache nobody has driven; a fish user
keeps today's cost and can never be stale. A bash login shell never reads
`.bashrc` at all, which is a pre-existing gap and not this phase's. An
invalidation revises nothing already running: the value left this process on
the `-e` argv and now lives in the tmux server's session environment, so the
promise is about the NEXT session and always was. And the deliberate refresh
spawns one login shell, which is exactly what a person who pressed it asked
for.

A person who has named no shell variable spawns nothing here at all, at boot
or ever.
@gregce

gregce commented Sep 17, 2026

Copy link
Copy Markdown
Owner

Closing this one — the substance of it shipped in 0.107.0, by a different route, and I've written the full account on #20 so it's in one place.

Short version. Your interactive-shell instinct was right and is in there: Tortie uses an interactive shell for the probe that reads your variables, rather than for every session. It couldn't go on the session path — measured, an interactive shell costs about a second against ten milliseconds for a login shell. And this version wouldn't have reached pi anyway: withLoginShellFlag is only called on the plain-shell branch, so -il never touched an agent session.

The ~-in-PATH half I checked rather than assumed, and it isn't a bug. A literal ~ in PATH doesn't resolve in zsh either — export PATH="~/bin:$PATH" then command -v finds nothing, while $HOME/bin finds it instantly. Tortie copies your PATH faithfully, so it fails exactly where your own shell fails. Making it expand would have Tortie finding binaries your terminal can't, which is a worse problem than the one it solves.

Thank you for opening it, and for saying plainly that it was untested. That was the right call.

@gregce gregce closed this Sep 17, 2026
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.

Make each new session spawn not as a sub process but as a completely new process.

2 participants