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.
_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.
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.
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.
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.mdstarts with a heavy ritual will happily run it unsupervised across the whole fleet. Seesession-startprompt.example.txt— rename it tosession-startprompt.txtto 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.
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 clonePut 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.
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 profileLink 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.
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).
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.
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.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.
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.