Repository navigation
Added
- zsh is now provisioned as a native runtime dependency, alongside tmux and
direnv. It is the interpreter a session prefers (SESSION_SHELL_PREFERENCEis
("zsh", "bash")) and the macOS default, so on a host without it the shell that
actually runs your sessions was also the one nothing verified -- 16 real-shell
signal regressions skipped while bash, the fallback, stayed fully covered.
Two rules shape it, both learned from the tmux loader bug:- It installs where the host cannot take it away: a prefix under your own
data directory ($XDG_DATA_HOME/ai-cli-utils/native,%LOCALAPPDATA%on
Windows), never the package manager's default. A conda base prefix can sit on
a filesystem the platform rebuilds -- measured on a managed notebook host
whereconda inforeports/opt/condaanddfputs it on an ephemeral
overlay while only$HOMEpersists -- so installing there yields a tool that
silently vanishes on every restart. - A launch adopts, it never installs. Adoption is a directory stat and a
PATHedit, so an already-provisioned zsh is picked up with no subprocess at
all. Installation belongs toai setup,ai updateandai doctor, because a
package-manager solve is minutes and no session may wait on one.
ai doctorreports zsh alongside bash and tmux, and-n/--dry-runreports
without provisioning. (AI-CLI-s2q2)
- It installs where the host cannot take it away: a prefix under your own
- Explicit commands may now escalate to a system package manager with a password
prompt; launches still never can.attempt_installstakesallow_root, which
defaults to False and is opted into only byai setupandai doctor-- and even
then only whensudoexists and stdin is a terminal, because a prompt nobody
is watching does not fail, it blocks forever. The exact command is printed before
the password is asked for, the password is collected by a separate visible
sudo -vso output capture cannot swallow the prompt, and the installer then
runs undersudo -nso it can never silently re-prompt. Verification is never
disabled and there is no--insecurepath. (AI-CLI-s2q2)
Fixed
-
State, cache, config and data directories can no longer resolve relative to the
current working directory, which is how a captured process listing came to be
written into a repository checkout instead of your home directory. The cause is
a distinction that only shows up for a variable that is set but empty:
os.environ.get("XDG_STATE_HOME", default)returns""rather than the
default, andPath("") / "ai-cli-utils"is the relative path
ai-cli-utils, so everything built on it resolved against the cwd. Measured
side by side withXDG_STATE_HOME="", the old code produced
ai-cli-utils/remote-ps-cache.jsonand the new code produces an absolute path
under$HOME. One site made this more than misplaced clutter: the CC
update-staging reaper deletes under its resolved path, so a relative base
aimed a delete at<cwd>/claude/staging. All seven base-directory reads now go
through a singleresolve_base_dir()that ignores a value which is empty or
non-absolute (as the XDG Base Directory spec requires for relative values) and
rejects a relative fallback outright. An AST sweep of the source tree fails the
suite if any module reads one of those variables directly again, since the
behavioural tests cannot see a call site that does not exist yet.
(AI-CLI-583m) -
Test fixtures no longer leak resources when a host-capability probe decides to
skip.real_tmux_socketcreated a temp directory and started a probe tmux
server, then decided whether to skip -- with thetry/finallythat cleans
both up beginning below the skip, so on the skip path neither was released.
That path is by definition the one taken on hosts where tmux misbehaves. An AST
sweep found four "acquire, then validate" sites; three are fixed by probing
first (aPATHlookup or a filesystem capability check builds nothing) or by
opening thetryabove the probe, and the fourth is legitimate -- itswrite
is the probe's own input -- so it is allowlisted by name with the reason
attached. A meta-guard intests/test_skip_hygiene.pynow enforces both correct
shapes, with positive and negative controls plus a stale-allowlist check so an
exemption cannot outlive the code it covers.
(AI-CLI-bug-tests-skip-capability-probe-bfqy) -
A launch no longer dies at
tmux new-sessionwhen tmux is on PATH but cannot
load a shared library.ai cused to printtmux version unavailable (binary does not run)and thenlaunching inside tmuxin consecutive lines,
synchronize a worktree, and only then fail with the dynamic loader's error --
because the preflight asked whether tmux was on PATH while the report asked
whether it ran, and nothing acted on the report. Now the launch self-heals:
it reads the loader's own error, looks for the named library in directories
around tmux's install prefix (including one nesting level down, where extracted
self-contained bundles keep theirs), points the loader at what it finds, and
re-runs tmux to prove it worked. Nothing is installed, downloaded, or written to
the machine, and a repair that cannot be demonstrated is rolled back. Because
it is re-derived on every invocation, a host whose system libraries are wiped by
a restart or a rebuilt container fixes itself on the next launch. If no such
library can be found, the launch degrades to bare mode -- naming the soname, the
directories searched, and theAI_CLI_LIBRARY_PATHoverride -- before a
worktree is created rather than after. (AI-CLI-i2ih,AI-CLI-d89q) -
ai c --dry-run(andai g/ai p/ai cx) now genuinely does nothing. The
flag was documented but never declared, and because the session commands set
ignore_unknown_optionsso engine flags can be forwarded, it was silently
passed to the engine and the launch ran for real -- creating a worktree, a
branch, registering workspace trust, reinstalling the CLI, and starting a
session. It now prints the resolved plan (engine, tmux-vs-bare after config and
preflight, session name, worktree path, branch, sandbox) and exits above every
write. (AI-CLI-wepi)
Changed
-
A missing tmux no longer blocks a launch. The launcher now attempts one
unattended install (rootless managers first: micromamba/conda/brew, then the
system ones when running as root) and falls back to bare mode with a notice
naming what was lost. Previously every non-Windows host without tmux printed
remediation and exited 1, making an enhancement a launch precondition.
Resolution order is unchanged where it was already right:[session] use_tmux
wins outright, and with no setting tmux is the default. (AI-CLI-yrpa) -
ai doctorreports tmux by runningtmux -Vrather than only finding it on
PATH, and says so explicitly when tmux resolves but cannot execute. A tmux that
died on a missing shared library previously read asOK tmuxwhile no session
could start. It now also performs the loader repair and, when that succeeds,
prints the exactexportline that makes it permanent for shells outsideai. -
Installing or reinstalling verifies tmux by executing it, alongside the
existing direnv bootstrap, and never fails the install over it -- there is no
native tmux on Windows to provision, and a missing enhancement must not make the
package uninstallable. (AI-CLI-i2ih) -
Every tmux decision now hangs on execution rather than resolution:
install_tmuxverifies withtmux_runsinstead oftmux_present, so a package
manager that lands a tmux which cannot load its libraries is reported as the
failure it is. A tmux that is present but broken is deliberately not sent to a
package manager: the broken binary keeps its place on PATH, so the install could
not be verified to have fixed the tmux that will actually run, and the launch
would pay the manager's timeout on every attempt to change nothing.
(AI-CLI-d89q) -
Corrected stale-session documentation: launch-time housekeeping does not
terminate tmux sessions. Start the independent reaper with
ai session-reaper start; it observes by default and requires explicit
mode = "reap"opt-in before it can reclaim an eligible session.
Added
-
The stale-session reaper now records generation-fenced heartbeats and uses
independently verified liveness and staleness evidence before it can reclaim
a session. Its default mode is observation; missing or invalid evidence
preserves the session. (AI-CLI-tdm6; 8a25e04, 268970b) -
ai pandai cxlaunch pi and Codex CLI sessions with the same worktree
isolation, resume behavior, and terminal integration as existing session
engines. (AI-CLI-o3gi,AI-CLI-z7ms; 35c5eef, a77c453) -
Remote sessions can select a named machine with
ai c -R -m/--remote-machine <alias>; legacy single-remote configuration
remains supported.ai ssh [ALIAS]opens an interactive shell using the same
remote-machine configuration. (AI-CLI-d2cu,AI-CLI-fzm0,
AI-CLI-o7xq; bb0400c, 4f133da) -
ai session-auditsurveys titled Claude Code sessions and can safely drive
adoption for sessions thatai ccannot resume. (AI-CLI-168; 0576159) -
ai syncnow includes Claude Code task records and rewrites transcript paths
for the receiving machine, so synced sessions can be resumed there.
(AI-CLI-n7h7; b1ecdd0) -
Session recovery uses a portable process probe on macOS and Windows as well
as Linux. (AI-CLI-session-reclamation-linux-only-zusn; 46dda09) -
Session launch can bootstrap
direnvportably, with a clear bypass when it
is intentionally unavailable. (AI-CLI-ai-cli-utils-abvz; b3515c5) -
ai session-adoptadopts a Claude Code session that was started without
ai c— a plainclaudein a repo root — soai c <n>resumes it from the
right worktree, losing nothing.ai cc-migratemoves the transcript and
nothing else, which is not enough: some session state is keyed by the project
slug (the cwd, slugified) or by a task namespace derived from the session UUID,
and only those break. The new command ensures the worktree exists (reusing an
existing one as-is, never clobbering it), moves the transcript via the existing
cc-migratemachinery, merges the CC task namespace into the pinned one,
copies auto-memory, and then verifies with the launcher's own lookup that
ai cgenuinely resolves the result — exiting non-zero if it does not. Task
ids are namespace-scoped, so the same1.jsonexists in both namespaces
holding unrelated work; colliding files are renumbered rather than overwritten,
with each file'sidand inter-task references remapped to match. Auto-memory
is copied and never moved (the memory directory is shared by every session that
ran from that root, so moving it would strand them) and never overwritten (a
worktree's own memory is about the work that happened there). Three refusals
precede any write: a live session (checked by reading/proc/<pid>, since a
session record outlives the process that wrote it — matched by name or
transcript UUID, or by a session holding the destination worktree, but not
every session merely sharing the source root), a duplicate title, and
insufficient free space (a copy-then-verify adoption transiently doubles a
transcript, and an ENOSPC mid-write truncates it). Two transcripts claiming one
title make resume nondeterministic, so that case is an unconditional human
gate: the command stops, prints both candidates with the detail needed to tell
them apart, and proposes the lowest index claimed by neither a worktree nor a
title — it never picks by size or mtime,-y/--yesdoes not cover it, and
applying the remedy requires the human's title or index on a second
invocation. Bulk mode (-a/--all) pauses on a collision and continues with the
remaining sessions. Procedure, including the by-hand equivalent of every step:
docs/tools/cc-session-adoption.md. (AI-CLI-168)
Changed
ai handoffand its cross-machine queue, NATS ingress, and automatic launch
hooks are removed entirely; the command no longer exists. NATS continues to
support its remaining messaging features. This intentionally removes the
queue's offline, cross-machine, and durable lifecycle semantics.
(AI-CLI-archive-retire-exjn; 86c41d0)- BREAKING (config): the
cc-usage pushlegacy usage configuration section
is renamed to[usage_api]. The old name was a private platform name that did
not belong in a public package, and it did not describe what the section
configures: a REST endpoint that CC token-usage events are POSTed to. The keys
inside it are unchanged (api_url,api_key), and there is no fallback to the
old name — a populated legacy section is now simply ignored. If your own
config.tomlhas that legacy section filled in, rename its header to
[usage_api]; nothing else in your config needs to change. If you miss it,
ai cc-usage pushfails withusage_api.api_url and usage_api.api_key must be set in config.toml under [usage_api]rather than pushing. A commented-out
legacy block (the untouched template default) needs no action; the new
template ships[usage_api]. (AI-CLI-189)
Fixed
-
The stale-session supervisor no longer kills a predecessor
mosh-server
process without proving it belongs to the current session. (AI-CLI-sdgi;
4a534ff) -
ai cc-usage push, quota operations, and copier updates now return distinct
exit codes for configuration errors (78), transient failures (75), and,
for copier updates, a partially mutated state requiring human resolution (3).
(AI-CLI-15vn; fe9da7b) -
Remote named sessions use the identity allocated on the remote machine;
unnamed remote sessions no longer collide. Remote launch probes its available
shell, limits failed mosh retries before falling back to SSH, and reports a
suspicious fast mosh exit. (AI-CLI-209,AI-CLI-rjxm,AI-CLI-gg9s,
AI-CLI-jbyo; 999a0dd, 582017d, 8fe0796, 893fd10, ecb3dfd) -
ai syncserializes staging-repository mutations, rejects real conflict
markers before committing, preserves marker examples in ordinary files, and
can use Codex before its existing fallback for memory-conflict merges.
(AI-CLI-5o8j,AI-CLI-0wmi,AI-CLI-ezcr; 5859304, ab3f264, f69f9da) -
ai cresumes the exact matching transcript UUID, and session cleanup no
longer kills unrelated live sessions while still reaping verified dead ones,
including remote sessions. (AI-CLI-7d3r,AI-CLI-p2qj,AI-CLI-7cyn,
AI-CLI-pzjy; 061e720, a223fe5, 549abe9, 1532f49) -
ai ws pullaborts and reports a failed worktree rebase instead of leaving
the worktree mid-rebase and reporting success. (AI-CLI-fbz5; 7563f6c) -
Windows session launch and the
direnvtrust probe work without Unix-only
executables or administrator privileges. (AI-CLI-worktree-envrc-auto-approval-not-vtxx,
AI-CLI-windows-test-suite-failures-nvqt; 89e471d, 4b0f076) -
ai creuses an empty session-worktree slot and creates it at the repository
root rather than the caller's current directory. (AI-CLI-ai-c-1-msxj;
0165e25, c1e0c8b) -
aicould vanish from your shell entirely, reportingbash: ai: command not found, for as long as an installer run took to finish. Self-update installed
the package plainly into its own environment, which silently converts an
editable install into a copied snapshot. The fleet installer treats a missing
editable marker as damage and repairs it by reinstalling the tool from scratch —
and that repair deletes and rebuilds the whole environment, taking theai
entry point with it. So the two owners took turns undoing each other forever,
and every installer-side repair was an outage window. The failure looked random
because it depended on which of the two had run more recently, and it could not
be defended against from inside the program: the command is gone before a single
line of it runs. Self-update now preserves editability, so the marker survives,
the installer sees nothing to repair, and the destructive path is never taken.
The same change fixes a quieter symptom of the same cause — between a
self-update and the next installer run,aiwas a stale copy that no longer
tracked its source, so edits appeared to have no effect. -
ai ccould crash withModuleNotFoundError: No module named 'ai_cli.session_adopt'when two launches ran at once. It was never flaky: two
conditions had to coincide.ai cauto-updates by runningai update --force,
anduv tool installrewrites the very installation the running process imports
from. Only one launch wins that lock; the loser used to treat "someone else is
updating" as "nothing to do", so it kept running while its own module files were
replaced underneath it. It then crashed only if it also had a prior transcript
to resume, because the import that failed sits in the resume path. So sibling
launches in the same burst survived untouched, which is what made it look random.
The loser now waits for the other update to finish and restarts itself, and the
liveness check degrades instead of raising if a module goes missing mid-run. -
ai c <n>no longer reinstalls the package, or prints the installer
transcript, on nearly every launch (AI-CLI-ww8o). The launch-time staleness
check compared the repository'sHEADagainst a stamp, so any commit — a
docs edit, a task-tracker sync — armed the next launch on every machine for a
fullgit pullplusuv tool install, whose wholeResolved / Prepared / Installedblock streamed into the terminal immediately before the session
painted.HEADis the wrong signal in both directions: it also fails to move
for an uncommitted edit undersrc/, which is the most common way the
installed build goes stale while a change is being tested. The trigger is now
a content fingerprint of the files that actually ship (pyproject.tomland
src/), recorded at install time, so an unrelated commit costs nothing and an
uncommitted source edit is now picked up where it previously was not. The
reinstall itself keeps the unique.post<timestamp>version that stops uv
serving a cached build — the guarantee the unconditional reinstall existed for
— and reports one line naming the version and why it rebuilt, withgit's and
uv's output captured. Failures are never quiet: the captured transcript is
printed in full on stderr and the stamp is cleared so the next launch retries.
AI_CLI_UPDATE_VERBOSE=1restores the full transcript at launch,ai update
gains-q/--quietand-v/--verbose, andai update --forcestill forces a
cache-bypassing reinstall on demand regardless of the fingerprint. -
ai updateno longer rewritespyproject.toml's line endings, and
.gitattributesnow pins every text file to LF in the index and in every
checkout (AI-CLI-mbci). The version bump round-tripped the file through
Path.read_text()/Path.write_text(), andread_text()applies
universal-newline translation: a CRLF file came back LF, so the restore
reverted the version but silently converted the whole file.pyproject.toml
had itself been committed as CRLF from a Windows host — the only such file in
the repository — so every checkout showed a permanent 172-line phantom diff
that re-appeared after every update run, and only thegit checkout -- pyproject.tomlopening the next update kept it from accumulating. The bump now
reads and writes bytes, so the restore is byte-identical even when the install
between the two writes raises, and the committed file is normalized to LF.
Details:docs/bugs/pyproject-crlf-round-trip.md. -
Exiting a session could leave its process stopped rather than gone, and the
launcher then refused to resume the name (AI-CLI-2139). A process in stateT
is fully present in/proc, holds its pid and its open files, and never resumes
on its own, so the registry check's two questions — does the pid exist, does its
start time match the record — were both answered "yes" for a session nobody was
using.ai c <n>printed "still running", dropped--continue, and started a
new differently-named session; nothing ever reaped the stopped process, so the
name stayed reserved. The terminate that looked successful was the same trap: a
stopped process does not run, so it queues theSIGTERMandkillreturns 0
without anything happening. The liveness check now reads the/procstate field
and classifiesT/t/Z/X/xas abandoned rather than live, and the
launcher ends an abandoned process it can positively identify —SIGTERM,
SIGCONT, bounded wait,SIGKILL, aimed at the process group so a
wrapper's children are not orphaned — confirms the outcome from the absence of
/proc/<pid>rather than from a signal's return code, prunes the stale record,
and says what it found and what it did before resuming. Reclamation is scoped to
the session being resumed, so a launch never becomes a fleet-wide reaper; a
genuinely running session still blocks its name and is never signalled; and a
record that cannot prove which process its pid is (no matching start time) is
reported and left alone rather than killed. Procedure, including the
verification step to run after any exit:
docs/procedures/exiting-a-cc-session.md. -
Data loss: creating a session worktree could delete nested git worktrees,
including commits that existed nowhere else (AI-CLI-200)..worktrees/<name>
carries two incompatible meanings:ai c <name>wants that path to be the
session's own checkout, while per-task agent worktrees are nested inside it as
<name>/<task>/<leaf>. A session launched from a repository root therefore finds
that container sitting exactly where its own checkout must go. Two defects met
there and masked each other. First,create_worktreedecided whether the
directory was a registered worktree with a substring test against
git worktree list --porcelain, and…/.worktrees/nameis a substring of the
lineworktree …/.worktrees/name/leaf— so a container with no checkout of its
own reported as registered. Second, the branch taken when that test fails is an
unconditionalshutil.rmtree, which reached with a container in place deletes
every nested worktree and any unpushed commit in it. The safe outcome was luck,
not correctness: the substring happened to match, so the deletion was never
reached, and fixing only the substring test would have armed it. Registration is
now matched line-exactly, and the deletion refuses to run on any directory
holding a git checkout — registered or not, since an unregistered clone is
invisible togit worktree listyet still holds commits. The refusal names the
colliding paths and thegit worktree movethat relocates them; nothing is moved
automatically, because relocating a human's unpushed work is a human decision. A
stale directory git knows nothing about is still replaced, so a leftover from an
interrupted run cannot block a session forever. -
ai session-adoptreported success into a destination that held none of the
repository's content (AI-CLI-200)._ensure_worktreeaccepted any existing
directory (is_dir()), which cannot distinguish a git checkout from an unrelated
directory of the same name — so the collision above also made adoption rewrite
every recorded working directory in a transcript to a path with no repository in
it. Adoption now verifies that its destination is a worktree of that
repository and otherwise refuses with the collision and the remedy named. The
refusal fires in-n/--dry-runtoo: previously the only signal in a preview was
the absence of a "worktree to create" line, which is how this stayed invisible. -
Four real-tmux launch integration tests failed on any machine with a
tmuxthat
is onPATHbut cannot execute — an extracted bundle whose shared libraries are
not on the loader path, for instance (AI-CLI-201). The guard asked
shutil.which("tmux") is None, which only stats the file and checks the
executable bit; the failure happens later, in the dynamic loader, so every test
ran and failed on an empty session list, reading as a defect in the launch path
rather than as a broken tool. The guard now runstmux -Vand skips with the
loader's own message when it does not exit 0. No assertion was weakened: with a
workingtmuxall five tests run and pass exactly as before, and the skip is
scoped to the fixture that needs a live server rather than to the module, so the
fully-mocked test in the same file keeps running either way. -
Every
ai updateand the auto-update at session launch printed a hardlink-fallback
warning to stderr when uv's cache and tool-install directories resided on different
filesystems (e.g., cache on EFS, tools on local NVMe). The install always succeeded,
but the warning fired on every update, training users to ignore update output. The
code now detects thest_devmismatch viaos.stat()and passes--link-mode=copy
only when cache and target are on different filesystems, suppressing the warning
without degrading the hardlink fast path on single-filesystem machines.
(AI-CLI-148, BUG-008 indocs/bugs/uv-hardlink-fallback-warning.md) -
Seven tests covering the LLM memory-merge path (
_llm_merge_memory_conflictand
theapply_pull_filesauto-merge branch) had never executed anywhere: they were
guarded bypytest.importorskip("google.genai"), butgoogle-genaiwas not a
declared dependency in any group, so CI and every dev machine skipped them
unconditionally.google-genaiis now a dev-group dependency (test-only — the
runtime import stays lazy and optional) and the skip guards are removed, so a
missing module fails the suite instead of silently skipping coverage.
(AI-CLI-146, BUG-007 indocs/bugs/genai-tests-skipped-everywhere.md) -
ai quota scrapeleaked Claude Code's update downloads to disk at roughly 8 GB/day
(AI-CLI-131). The scrape launchesclaudein a throwaway tmux session and kills
that session unconditionally about 15 seconds later; Claude Code had by then begun
downloading any newer version into~/.cache/claude/staging/, so the kill truncated
the download mid-write and orphaned the partial binary. Nothing reaped it — Claude
Code only removes a staging entry it successfully installs. Under the*/10cron
that is ~144 orphans a day: the Hetzner box held 402 entries totalling 23 GB, 400 of
them created exactly on cron minutes, and reached 0 bytes free. The scrape now runs
Claude Code withDISABLE_AUTOUPDATER=1, so an ephemeral read-only scrape never
starts a download it cannot finish, and sweeps staging entries left idle for over an
hour after each run. The sweep only removes well-formed entry directories past that
age, so a download in flight for a real session is never touched. -
Session watcher busy-spun instead of polling once per second (
AI-CLI-129). The
loop paced itself withread -t 1 -r < /dev/null, which never blocks:/dev/null
returns EOF immediately, soreadreturns before its timeout applies. Measured at
~140 ticks/second against a specified 1, for the lifetime of every session — each
tick able to firetmux capture-pane,ai internal publish-heartbeatand a
sha256sumpipeline. Every duration the loop derives from its tick counter
(counter >= 10as a 10-second startup grace period,counter % 30as a 30-second
heartbeat,counter % 10as a 10-second config-hash check) was wrong by the same
factor. Replaced withsleep 1. -
ai update's live-session template refresh rewrote every session's stable launch
script unconditionally, and had no bound on how often it could run (AI-CLI-129).
An unchanged script is now left alone, so its mtime — the hot-reload signal every
running wrapper polls — is no longer bumped for nothing, and a plainai c
re-attach no longer makes the running wrapperexeca pointless reload. The
refresh now also refuses to run past 20 attempts in 60 seconds (tracked in-process
and in the state dir) and reports the runaway caller once per window on stderr,
rather than spawning atmux list-sessionssubprocess plus a write and a chmod per
live session for as long as the caller keeps asking.