Skip to content

Latest commit

 

History

History
515 lines (426 loc) · 33.8 KB

File metadata and controls

515 lines (426 loc) · 33.8 KB

The launcher — bringing a fleet up, and the cold-start gap

This part is the most environment-specific in the repository (Windows, Git Bash, mintty, PowerShell). Read it for the idea even if you cannot run the scripts: the cold-start gap it solves will appear in any setup where a capability is armed by the session itself.

Data and mechanics are separate files

_lib.sh                     the ONLY copy of the launch mechanics
projects.<host>.conf        data only: which projects, which directories, which extra dirs
autostart.<host>.local      which of them start on a fleet start — local, not in git

One config per machine, mechanics shared. This split existed for operational reasons — the config differs per host, the mechanics must not drift between them — and it paid twice: when we opened this repository, the entire mechanics stack turned out to be free of personal data by construction, because everything personal lives in the config files that stayed private. See projects.example.conf for the format:

projects=(
  "app|/c/code/app|--add-dir \"$HOME/session-bridge\""
  "notes|/c/notes||instructions=notes"     # 4th field: CLAUDE.md kept outside the repo
  "scratch|/c/code/scratch|"               # in the list, selected or not -- see below
)

Every line is an entry; the config is the list and nothing else. The two readers (the fleet start through cc_all_entries, and the session manager) parse that one form, and a test holds the parser against the sourced array. Directories passed with --add-dir are pulled before the start too, if they are git working trees: a repo reached only that way never got a pull before, and one of ours sat 53 commits behind while a session was editing scripts in it — caught by the rejected push, i.e. after the work.

The autostart selection is local

Which projects a fleet start brings up used to be a #off prefix in the config — a versioned file. But the checkboxes are a convenience, toggled all the time depending on what is being worked on; their state is no signal. In a shared repo every toggle produced a commit that arrived on the other machine as a change and was read there as intent.

So the selection lives in autostart.<host>.local next to the config, ignored by git, one project name per line, written by the session manager ("Save"). The config is the list (name, path, extra args, instructions=) and the local file the selection. An empty selection starts nothing and says so loudly; the starter console stays open.

If the local file is missing (fresh clone, new machine) there is nothing to derive a selection from, and that is a state of its own: the fleet start starts nothing, says why, and opens the session manager — tick once, save, start. The alternatives were weighed and rejected: "all on" opens a window per project on the first run; "all off" leaves the user without a way out; a default in the repo would be a shared opinion about checkboxes again, the very thing this removes. (The migration went in two steps: first the local file was seeded once from the config's old #off state, so nobody clicked anew; once every machine had its file, the prefix left the config.)

A stale #off line — a config not pulled, an old instruction followed — is a comment to bash and would hide the entry silently. So it is neither read nor swallowed: the fleet start and start-one.sh warn on stderr and name the entries, the session manager shows a warning box on load; none of them writes the config. Remove the prefix, the line stays. Local means unsaved: if the machine is lost, the selection is gone — acceptable for a convenience.

Instructions kept outside the project repository

Some projects live in a public or shared repository, and the CLAUDE.md for the session that works on them is not part of the product — it is Claude's working notes about the product. So the file is untracked in the project and kept in a separate, private instructions repository, one folder per project key. The 4th config field, instructions=<key>, declares that; before the start the launcher copies <clone>/<key>/CLAUDE.md into the working tree. Without the field nothing happens — only the declaration makes a missing file reportable.

The clone is expected as a sibling of this repository named _instructions, or wherever CC_INSTRUCTIONS_DIR points. Which remote you keep it on is your business; the launcher only needs a clone with a history — and the history is the point:

Why a git clone and not a sync folder: the baseline. A first version compared source against working tree only, and thereby conflated two situations — merely outdated (nothing touched here, changed over there; taking it over is lossless) and changed on both sides (touch nothing). When sessions alternate between two machines the first is the normal case, but it was reported as a conflict; the step helped exactly once per machine and then never spoke again when it mattered. A clone knows what it last delivered: if the working-tree file matches any version in the clone's history, it was not touched locally and is caught up; if it matches none, it was changed locally, and the launcher leaves it alone and says so. No stamp to maintain, and explicitly not the mtime — a cp or a pull stamps a file anew.

A merge conflict in the clone does not stop the start. On a conflict nothing is copied, so the working tree keeps its old, intact version — a file with conflict markers cannot reach the session from here; an abort would protect nothing, it would only force the repair. Instead the session starts on the local state and is given the task of resolving the conflict: it can read and judge both versions, a start script can only apply one rule. The task travels in the start prompt, appended to the arming ritual — messages on stderr are read by nobody inside the session — and only the session whose own key is in conflict gets it; on a fleet start all the others would otherwise race for the same fix. The note warns that the resolved version takes effect at the next start.

The write path is instructions-sync.sh <key> [dir], meant for the wrap-up ritual: it copies the working-tree CLAUDE.md into the clone, commits and pushes, and refuses to write into a clone that is in conflict (it would commit the markers). The baseline only holds while somebody also writes: a clone that stands still while the file is maintained in the tree makes the launcher copy an old version in and report the new one as a local change.

CC_NO_INSTRUCTIONS=1 skips the step for a run.

The cold-start gap (the interesting part)

A watcher is armed by the session itself, on its first turn, following an instruction in its CLAUDE.md. Our launcher restarted every session with --continue and no prompt. That starts the session and restores its history — but it runs zero turns. The session is up, looks perfectly healthy, and has never read its own start ritual.

Measured after one machine restart: 12 sessions running, 1 watcher armed. The push layer had quietly hung on the very hand movement it was built to abolish, and nobody noticed until someone counted processes.

The fix is three lines: the launcher appends a start prompt, so exactly one turn happens per session, unattended. Arming stays the session's own job — a launcher-started watcher would have no channel into the session, because the notification has to come from the session's own Monitor task.

Same fleet after the fix: 12 of 12 armed within three minutes, no hand movement.

Two details that are easy to get wrong and cost us an evening each:

  • The prompt must fence the session in. Ours says, in effect: run only the start ritual, begin no task, change no file, commit nothing, send nothing, summarise in five lines, then wait. Without that fence, a session whose CLAUDE.md starts with a heavy ritual will happily run it unsupervised across the whole fleet. See session-startprompt.example.txt — rename it to session-startprompt.txt to activate it; if the file is missing, sessions start exactly as before, unarmed but working.
  • Keep the wording in a file, not in the command line. Ours goes through mintty -e bash -lc "…", i.e. two levels of nested quoting, and the backticks that markdown wording is full of would be command substitutions there. $(cat file) sidesteps all of it.

Alternating between two machines: pull before the start, memory in the repo

When sessions alternate between two machines, only what is pushed travels — the repo and the bridge — and not the profile under ~/.claude/ (memory, transcripts, settings, commands). Two pieces of mechanics for that:

Pull before the start. cc_launch pulls the project repo before the session starts — fast-forward only. The remote is CC_PULL_REMOTE if you set it and the repo has it, else the branch's upstream, else nothing (said, not pulled). A local lead, a dirty tree or a sleeping remote are reported, and the session starts anyway: a silently skipped pull would be the worse failure. Not a repo: silent. --no-pull (both starters) or CC_NO_PULL=1 skips the step, e.g. offline. ssh runs with ConnectTimeout=5 and BatchMode so that neither a sleeping host nor a passphrase prompt holds up the start.

Memory in the repo. Claude Code keeps a project's memory under ~/.claude/projects/<slug>/memory/, and the slug is the path (D--work-app on one machine, C--work-app on the other) — so it never travels. launcher/link-memory.sh moves it to <repo>/memory/ and turns the profile path into a junction (Windows) or symlink pointing there; once per machine:

bash launcher/link-memory.sh /d/work/app            # repo mode:  <repo>/memory/
bash launcher/link-memory.sh --git /d/work/app      # git mode:   <parent>/_session-memory/<id>/
bash launcher/link-memory.sh --cloud /d/work/app    # cloud mode: <cloud>/_session-memory/<id>/

A setting can move the memory out from under all of this. Claude Code has a settings key of its own, autoMemoryDirectory, which relocates the auto-memory folder. Where it is set the profile path is no longer the memory but a leftover: reads and writes both go elsewhere, and an existing memory under the profile path simply becomes invisible — no migration, no warning. link-memory.sh therefore refuses every mode that reasons from the profile path (--mark-only excepted, it never touches it), and the launcher reports the situation once at start-up instead of drawing a shortfall warning from a stamp nobody maintains any more. What made this a refusal rather than an adaptation is worth stating, because the failure was silent in the reassuring direction: --stamp used to stamp the orphaned folder with exit 0 and a success message — counting 2 files for a memory that had 3 at that moment — and that stamp is exactly what the shortfall warning is built on. A wrap-up would have reported green while the real memory was never committed and never pushed.

Only whether the key is set is checked, never what follows from it: resolution spans five settings layers plus two environment variables, and reimplementing that here would mean keeping it in sync with someone else's precedence order. Only a string value triggers the refusal — null means "not set" to the resolver itself and a non-string Claude Code rejects, so in both cases the profile path stays valid and these tools are right; the refusal sits in the wrap-up path, where a false positive would block saving work. A --settings <file> on the command line is invisible to the check, which is why the message says so: a guard whose limit goes unmentioned is taken for exhaustive. If you want the setting, either remove it and let the link carry again, or keep that memory by hand — these tools then stay out of it.

Which mode? Repo mode only for infrastructure repos that exist for this purpose alone (the bridge tooling, a launcher config repo): the memory rides the push the wrap-up makes anyway, and every memory write shows up as a diff in git status. Everything with a public or private product repo takes git mode: a repository of its own per project, cloned from $SESSION_MEMORY_SSH_HOST:$SESSION_MEMORY_SSH_PATH/memory-<id>.git. The memory is Claude's working notes — customers, prices, failures — so it belongs in neither a public nor a shared history, and it cannot go in the product repo either, where every memory write would be a commit.

One repository per project, not one for all. A clone fetches the whole tree, and whatever enters the history then sits on every machine that ever clones it, undeletably. One shared repository would put every project's notes on every machine that opens any project — including the projects that machine never touches. Per project, a machine holds only what it uses: the separation is built rather than incidental. Worth checking before you assume the incidental version is good enough — on the machine that prompted this, every file of every project turned out to be materialised locally already, including the projects that had never been opened there.

What git mode does not do is carry writes for free. A sync client did; git does not. That is what the wrap-up step is for:

bash launcher/link-memory.sh --push /d/work/app     # commit + push the memory clone

Put it in your wrap-up ritual next to --stamp, and let the launcher fetch it before the session starts (cc_memory_pull, wired into cc_launch). Skip either half and the move saves less than what it replaced — that is the one way to get this wrong. --push tells the three situations apart by property rather than by folder name (own .git → push; inside another worktree → repo mode, that repo's commit takes it along; a linked target that is neither → still the cloud folder; an unlinked profile folder → carried by nobody, and it says so loudly). The name cannot decide it: the cloud folder and the clone root are both called _session-memory. That last distinction matters more than it looks — telling someone "the sync client carries it" about a memory that travels nowhere is falsely reassuring at exactly the project whose state is in fact saved nowhere.

Two machines editing the same memory between pushes end in a rejected push, reported with the command to catch up; --ff-only on the fetch side refuses to paper over it. That is a merge by hand, and deliberately so — the memory is prose, and no tool should guess which sentence wins. Migration markers (.migrated-<host>) and the per-machine stamp never enter the repository: they belong to the old folder and to one machine, and a shared memory would otherwise carry one machine's marker into the other's history.

Retiring the old folder: --retire. Migrating alone does not reach the goal — the old copy stays where it is, on every machine that syncs it. --retire verifies file by file by content, through the link, checks that the clone itself is clean and pushed (it is about to remove the last independent copy — if the clone then hangs on this disk alone, the move abolished the backup instead of relocating it), demands a marker from every machine that has a projects.<host>.conf, and only then moves the folder to _retired/<id>/ — a mv, not an rm, so on the other machine it looks like a move rather than a loss. A machine that does not carry a project at all can never produce a migration marker, so it says so explicitly with --mark-only --name <id>; without that escape the condition would be unsatisfiable and --retire blocked forever.

A move moves, it does not rename. If the memory is called something else at the new place than at the old one, --relink aborts and hands you the command with the old name. The case this catches is not hypothetical: a second checkout sharing another session's memory by link resolves its own id, and the migration would have given it a fresh empty memory beside the full old one — silently. Measured across every project on the machine where this was built: identical all but once, and that once was exactly this.

In cloud mode (superseded, still available while a migration runs) the sync client carries it without a commit (with the cloud's own version history); the cloud root is machine-dependent (a short list in the script, or SESSION_MEMORY_DIR, which wins), <id> is resolved in this order, all lower-cased: --name, else line 1 of .session-id in the tree, else the participant table of the bridge README matched by working directory (set SESSION_BRIDGE_DIR), else the directory name. The table matters because a directory name often differs from the session id; the path is compared as a whole path, once as written and once without its drive letter, so a second machine mirroring the layout under another drive resolves without a second column of paths nobody can verify. Caveat: the memory is read once at session start with no warning line — after a cold start give the sync client a moment before starting.

Moving an existing link: --relink. If the profile path already links somewhere else the run aborts; with --relink the link is moved and the files come along. Two cases: a project leaving repo mode for cloud mode, and a second checkout that shares another session's memory by junction and should point at the same cloud folder. The old target folder is moved aside (….pre-link) only if it lies inside the repo you passed — otherwise it is left untouched and only named, because there it may be another session's memory. If it was versioned, git rm -r --cached memory and memory/ in .gitignore follow — and then git check-ignore -v memory/: an ignore rule that silently does nothing looks exactly like one that works. If the machine already has a memory of its own, its files move along; a MEMORY.md that differs on both sides — the normal case for a second machine — is merged (repo lines first, then the lines only the profile has). Any other file that differs on both sides aborts the run with nothing touched -- and the abort hands over what is needed to resolve it: both full paths and how many lines differ. Merging is not the script's job. A line tool would have to guess which version holds; the session wrote both versions and understands the content, so it consolidates them itself: read both, write the result into the target version, delete the profile version, run again. The script only refuses to guess and

The more dangerous half is the files that exist on one side only. They raise no conflict, because there is no counterpart for anything to show up against — they are simply copied in. Two sessions consolidating a second machine's memory on the same day found three genuine contradictions with the target exactly there (a path that five target files asserted and the machine denied, a stale artifact, an obsolete diagnosis), while the one reported conflict file carried five idle words. So the tool names them before the abort:

[move] 3 file(s) exist only in the profile and come into the target: a.md b.md c.md
       The script compares nothing for these; the target holds no version.
       Read them before linking: one of them may contradict what the target
       already says (there it never shows -- both statements simply stand).

That line used to sit after the abort's exit, so it never appeared while a session was planning its consolidation work — only in the run that went through anyway. With an empty target the three explaining lines are omitted: there is nothing there to contradict, and the warning would be false.

The same blind spot applies to measuring such a situation: a per-file comparison sees the intersection and nothing else. Measure in two stages — per file for the intersection, and each one-side-only file against the whole target corpus.

On a second machine, "no profile memory" is not a problem. That machine has never held this project; the memory has been in the target all along and is only linked, nothing is copied. The message used to read like a deficiency in both situations. It now distinguishes them, and states the expected outcome so a session can tell a correct run from a broken one:

[new] no profile memory for '<slug>' -- normal on a second machine:
      <target> is already there (4 file(s)) and is only being linked.
      Expect: the profile path then shows the same number (4); nothing is copied.

With an empty target the original wording stays -- there it really is a fresh start. The behaviour was always right; the framing was what decided whether somebody stopped and asked.

What does not tell you whether a project still has its second machine ahead of it: the wrap stamp. It holds the last wrap, not the history, so a project that migrated on the second machine and later wrapped on the first shows the first one. A point-in-time value cannot answer a question about history; the migration's own completion records can.

keeps both sides intact until then. A second run reports "already linked", and every run cross-checks by listing the repo through the link. Removing the junction again: rm <path> in Git Bash (msys treats it as a link; rmdir says "Not a directory"). Checked: rm and rm -rf in Git Bash do not follow the junction, the repo stays intact — with PowerShell Remove-Item -Recurse that depends on the version, so do not delete there.

The stamp, and what the launcher makes of it. As the last step on the memory, your wrap-up ritual runs link-memory.sh --stamp, which writes <memory>/.last-wrap with <host> <UTC> <file count>. Before each start cc_memory_state reads it and says two things, staying silent otherwise:

[memory] app: 402 file(s) expected, 387 present -- sync still running (state other-host 2026-08-30T07:11:51Z (25 min ago)).
[memory] app: state from other-host, 2026-08-30T07:11:51Z (25 min ago), 55 file(s).

Why the file count and not just the time: a sync client transfers file by file, there is no atomic state — the stamp is itself a file and can arrive before the ones it vouches for. A pure freshness display would then say "up to date" over a half-loaded folder, falsely reassuring at exactly the dangerous moment. More files than stamped is normal (written locally since the wrap) and is not reported. In repo mode, exclude memory/.last-wrap in .gitignore: there git guarantees completeness itself, and the stamp would only be conflict fodder between two machines that both wrap.

The counterparts when leaving a machine belong in the wrap-up ritual: WIP commit and push are mandatory, the hand-over goes into a file that travels (the memory), the session is closed (two live sessions in one project both answer the same threads and both commit), and the sync client is given time to upload before the machine sleeps.

A command file can travel and still do nothing

Claude Code reads slash commands from two places: ~/.claude/commands (global, per machine) and <repo>/.claude/commands (project, travels with the repo). On a name collision the global file wins. So a command can be committed, pulled onto the second machine, sit right there in the listing — and have no effect at all. In our own setup a wrap-up command ran for six weeks in a version six weeks old for exactly that reason; the current one was next to it, unused. Nothing looks broken: the profile under ~/.claude/ does not travel, and nobody compares it by hand.

The fix is a link, not a better report. link-commands.sh turns ~/.claude/commands into a junction to <repo>/.claude/commands, so there is only ONE file: a git pull puts new commands in front of every session on the machine, and editing a ritual file is editing the working tree — it shows up in git status and travels with the next commit.

bash launcher/link-commands.sh            # link (idempotent)
bash launcher/link-commands.sh --status   # 0 = linked, 10 = not linked
bash launcher/link-commands.sh --unlink   # undo: copy the content back into the profile

Link once per machine. The tool refuses while anything is in the way: a file that exists only in the profile would become invisible behind the link (abort, it names the file); a changed profile version that is in no commit carries work saved nowhere (abort, with the diff to run). Only an older committed version may pass, because its content is in the history and nothing is lost. The old folder is moved aside rather than deleted, the result is verified THROUGH the link, and a failed verification rolls back.

Why not simply report it? Because that was the first answer, and it was not enough. The reporter below works — it correctly announced wrap.md: OUTDATED with a ready-made cp, and the profile on the second machine was still eight days old. Reporting does not replace pulling across. The timing makes it worse: you edit a ritual file while busy with something else, i.e. in a single session started by hand, whereas the reporter runs at the fleet start — the one moment when nobody is editing one. A SessionStart hook would not have fixed that but doubled it: settings.json is itself a profile file and travels no better than what it reports.

A reporter is a procedure; a link is a state. What does not change on its own may be checked rarely — which is why "is it linked?" is fine at the fleet start, although "are the contents equal?" was already too late there.

Two consequences worth knowing. Commands that were repo-only and worked through --add-dir become visible everywhere once linked. And without a working clone of the repository there are no commands at all — the same bet the memory link takes.

Where a profile is not linked, cc_check_commands still compares the two once per start run (a machine state, not a per-project one) and reports three cases, staying silent otherwise — plus one line naming link-commands.sh, even at parity, because parity only means the divergence has not happened yet. Where the profile is linked, it says nothing at all:

OUTDATED both exist and differ, and the global content appears in the repo file's history — an older checked-in copy. Fix: copy from the repo.
NOT IN REPO both exist and differ, and the global content is in no commit — it carries changes saved nowhere. Fix: adopt it, do not overwrite it.
NOT SHARED global only — it does not exist on the other machine. Fix: put it in the repo.

Repo-only is deliberately not a finding: those work through --add-dir.

Why the history decides and not the mtime. A git pull stamps the repo file with the checkout time, so it always looks newer than the global one — including in the incident above, where its content was in fact the current one. The blob hash does not lie; a timestamp here says only when a file was written, not which version it is. The test suite pins this: the outdated case is still detected after the repo file is given the newer mtime.

check-commands.sh is the same check by hand, with one addition — it reports the all-clear. During a start run silence is right; when you ask, you want to know that it looked.

A running session is not started twice

Neither the launcher nor the session manager used to check whether a project already had a session; the only skip reason was a missing directory. A second window means two sessions in one tree — and the second one is silent: its watcher arm steps aside because one already delivers for that id (the same picture as two checkouts sharing one id, from a different cause).

cc_session_running in _lib.sh reads Claude Code's own session registry, ~/.claude/sessions/*.json — one file per running session with pid, cwd and name, a by-product of native cross-session messaging — and compares name and cwd exactly, never as a substring (app is contained in app-product). cc_launch returns its own 2 for "already running": that is the normal case on a second run and must not keep the starter console open as an error; --force starts anyway.

Not the mintty window title. The title belongs to the window, not the session, and exec bash keeps the window open after claude exits — measured once: 16 windows, 15 sessions.

An entry counts only with a live pid. The registry file disappears only on a clean exit. After an unattended reboot the entries of every killed session were still on disk; the launcher took each one for running and started nothing — a second attempt worked only because Claude Code prunes dead entries when it starts. So the pid is checked against the running claude processes (ps -W, no PowerShell call per project); the process name matters, or a pid reused after the reboot would count. An empty process list means "nothing is alive", not "check broken" — after a reboot it is rightly empty, and exactly then the start must go through. CC_LIVE_PIDS injects the list for tests.

Two traps met while building it: the config carries msys paths (/d/work/x), the registry Windows paths (D:\work\x) — without the rewrite the cwd branch would have been dead and only the name would ever have matched, and a comparison that never matches looks like one that finds nothing. And test fixtures written with printf produced invalid JSON twice; the bash side greps and tolerated it, PowerShell's ConvertFrom-Json failed silently. Write fixtures with a JSON library, compact form (the registry has no spaces after the colons, and the grep relies on that).

Resuming: --continue only when there is something to resume

The launcher passes --continue so a restart does not throw away yesterday's context. That flag has one sharp edge: in a directory with no transcript yet, claude --continue aborts interactively ("No conversation found"). The window then sits there empty and the session never starts at all — the one case where the convenience flag costs you the whole session.

cc_has_transcript therefore looks for ~/.claude/projects/<encoded>/*.jsonl first, where <encoded> is the Windows path with every non-alphanumeric character replaced by -. No transcript, no --continue — and the launcher says which projects start fresh. A silently dropped --continue would be the worse failure: the session would run without its history and nobody would notice.

Two things about reproducing this. In print mode (-p) the abort does not happen — claude falls back to a new conversation there, so anyone testing with -p never sees the bug. And the path must be canonicalised before it is encoded: two spellings of one directory (an 8.3 short name against the long one) give two different slugs, and the existing transcript would not be found.

--fresh suppresses --continue deliberately, for every session in the run. Its purpose is not the empty context but the remote session: with --continue claude re-attaches to the existing remote session and inherits its old, usually hostname-generated name. Only a start without --continue creates a new one that takes the name from the config. Meant as a one-off — the price is the running context of every session.

That is the second of two names that have nothing to do with each other: --name sets the local one (prompt box, /resume picker, and the name field in the session registry), and it applies when resuming too. It is not cosmetic — cc_session_running compares that registry field, so without --name that half of the duplicate-start guard can never match and only the cwd branch carries it. Without --name, claude derives a name from the folder afresh on every start.

Closing a fleet

close-cc-sessions.ps1 kills the session windows and then collects leftover watcher processes — sparing any whose wrapper still hangs under a live session, so hand-started sessions are not disturbed. As a third step it measures the orphaned conhost.exe consoles over a three-second delta and kills the ones that spin: killing the windows is exactly the moment that leak is born (see watcher.md).

This second step is necessary because killing the window does not kill the watcher: the process tree is already torn (see watcher.md), so a tree kill never reaches it. The window dies from its terminal going away, which the watcher does not notice.

Session manager

session-manager.ps1 is a small GUI over the same config: see which sessions are up, start individual ones (start-one.sh), stop them, and toggle the autostart selection — its "Save" writes autostart.<host>.local, never the config. Convenience, not part of the mechanism. Its RUN marker reads the same registry as the launcher, with the same live-pid check, and compares name and cwd whole rather than as a prefix — the logic exists twice on purpose, because calling bash once per timer tick (every 3 s) would block the UI. Both copies say so.

It deliberately contains no non-ASCII character in code or UI strings. That is not style: the file is UTF-8 without a BOM, Windows PowerShell 5.1 reads it as ANSI, and a non-ASCII character inside a double-quoted string ends that string early — the rest of the line becomes code and the file stops parsing. Inside single quotes the same byte sequence parses fine and only mangles the text, which is the quieter and more shippable half of the problem. PowerShell 7 sees none of it, so the CI runs the 5.1 parser too.

Testing note, learned the hard way

Do not start test sessions from inside another Claude Code session. A session started that way inherits a marker (CLAUDE_CODE_CHILD_SESSION) and writes no transcript — so a later --continue finds nothing, no matter how long it ran. Add the trust dialog for unknown directories and the fact that -p conversations do not count as resumable, and you can lose an evening to three failed attempts whose cause is printed in the test window all along.

Test against a real, trusted project that is switched off in the autostart config instead.