Skip to content

v0.8.0

Latest

Choose a tag to compare

@github-actions github-actions released this 22 Sep 19:13
· 307 commits to main since this release
6b4969d

Added

  • zsh is now provisioned as a native runtime dependency, alongside tmux and
    direnv. It is the interpreter a session prefers (SESSION_SHELL_PREFERENCE is
    ("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
      where conda info reports /opt/conda and df puts it on an ephemeral
      overlay while only $HOME persists -- 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
      PATH edit, so an already-provisioned zsh is picked up with no subprocess at
      all. Installation belongs to ai setup, ai update and ai doctor, because a
      package-manager solve is minutes and no session may wait on one.
      ai doctor reports zsh alongside bash and tmux, and -n/--dry-run reports
      without provisioning. (AI-CLI-s2q2)
  • Explicit commands may now escalate to a system package manager with a password
    prompt; launches still never can. attempt_installs takes allow_root, which
    defaults to False and is opted into only by ai setup and ai doctor -- and even
    then only when sudo exists 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 -v so output capture cannot swallow the prompt, and the installer then
    runs under sudo -n so it can never silently re-prompt. Verification is never
    disabled and there is no --insecure path. (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, and Path("") / "ai-cli-utils" is the relative path
    ai-cli-utils, so everything built on it resolved against the cwd. Measured
    side by side with XDG_STATE_HOME="", the old code produced
    ai-cli-utils/remote-ps-cache.json and 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 single resolve_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_socket created a temp directory and started a probe tmux
    server, then decided whether to skip -- with the try/finally that 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 (a PATH lookup or a filesystem capability check builds nothing) or by
    opening the try above the probe, and the fourth is legitimate -- its write
    is the probe's own input -- so it is allowlisted by name with the reason
    attached. A meta-guard in tests/test_skip_hygiene.py now 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-session when tmux is on PATH but cannot
    load a shared library. ai c used to print tmux version unavailable (binary does not run) and then launching inside tmux in 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 the AI_CLI_LIBRARY_PATH override -- before a
    worktree is created rather than after. (AI-CLI-i2ih, AI-CLI-d89q)

  • ai c --dry-run (and ai g/ai p/ai cx) now genuinely does nothing. The
    flag was documented but never declared, and because the session commands set
    ignore_unknown_options so 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 doctor reports tmux by running tmux -V rather 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 as OK tmux while no session
    could start. It now also performs the loader repair and, when that succeeds,
    prints the exact export line that makes it permanent for shells outside ai.

  • 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_tmux verifies with tmux_runs instead of tmux_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 p and ai cx launch 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-audit surveys titled Claude Code sessions and can safely drive
    adoption for sessions that ai c cannot resume. (AI-CLI-168; 0576159)

  • ai sync now 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 direnv portably, with a clear bypass when it
    is intentionally unavailable. (AI-CLI-ai-cli-utils-abvz; b3515c5)

  • ai session-adopt adopts a Claude Code session that was started without
    ai c — a plain claude in a repo root — so ai c <n> resumes it from the
    right worktree, losing nothing. ai cc-migrate moves 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-migrate machinery, merges the CC task namespace into the pinned one,
    copies auto-memory, and then verifies with the launcher's own lookup that
    ai c genuinely resolves the result — exiting non-zero if it does not. Task
    ids are namespace-scoped, so the same 1.json exists in both namespaces
    holding unrelated work; colliding files are renumbered rather than overwritten,
    with each file's id and 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/--yes does 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 handoff and 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 push legacy 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.toml has 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 push fails with usage_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 sync serializes 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 c resumes 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 pull aborts 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 direnv trust 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 c reuses 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)

  • ai could vanish from your shell entirely, reporting bash: 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 the ai
    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, ai was a stale copy that no longer
    tracked its source, so edits appeared to have no effect.

  • ai c could crash with ModuleNotFoundError: No module named 'ai_cli.session_adopt' when two launches ran at once. It was never flaky: two
    conditions had to coincide. ai c auto-updates by running ai update --force,
    and uv tool install rewrites 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's HEAD against a stamp, so any commit — a
    docs edit, a task-tracker sync — armed the next launch on every machine for a
    full git pull plus uv tool install, whose whole Resolved / Prepared / Installed block streamed into the terminal immediately before the session
    painted. HEAD is the wrong signal in both directions: it also fails to move
    for an uncommitted edit under src/, 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.toml and
    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, with git'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=1 restores the full transcript at launch, ai update
    gains -q/--quiet and -v/--verbose, and ai update --force still forces a
    cache-bypassing reinstall on demand regardless of the fingerprint.

  • ai update no longer rewrites pyproject.toml's line endings, and
    .gitattributes now 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(), and read_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 the git checkout -- pyproject.toml opening 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 state T
    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 the SIGTERM and kill returns 0
    without anything happening. The liveness check now reads the /proc state field
    and classifies T/t/Z/X/x as 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_worktree decided whether the
    directory was a registered worktree with a substring test against
    git worktree list --porcelain, and …/.worktrees/name is a substring of the
    line worktree …/.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
    unconditional shutil.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 to git worktree list yet still holds commits. The refusal names the
    colliding paths and the git worktree move that 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-adopt reported success into a destination that held none of the
    repository's content (AI-CLI-200). _ensure_worktree accepted 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-run too: 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 tmux that
    is on PATH but 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 runs tmux -V and skips with the
    loader's own message when it does not exit 0. No assertion was weakened: with a
    working tmux all 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 update and 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 the st_dev mismatch via os.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 in docs/bugs/uv-hardlink-fallback-warning.md)

  • Seven tests covering the LLM memory-merge path (_llm_merge_memory_conflict and
    the apply_pull_files auto-merge branch) had never executed anywhere: they were
    guarded by pytest.importorskip("google.genai"), but google-genai was not a
    declared dependency in any group, so CI and every dev machine skipped them
    unconditionally. google-genai is 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 in docs/bugs/genai-tests-skipped-everywhere.md)

  • ai quota scrape leaked Claude Code's update downloads to disk at roughly 8 GB/day
    (AI-CLI-131). The scrape launches claude in 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 */10 cron
    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 with DISABLE_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 with read -t 1 -r < /dev/null, which never blocks: /dev/null
    returns EOF immediately, so read returns before its timeout applies. Measured at
    ~140 ticks/second against a specified 1, for the lifetime of every session — each
    tick able to fire tmux capture-pane, ai internal publish-heartbeat and a
    sha256sum pipeline. Every duration the loop derives from its tick counter
    (counter >= 10 as a 10-second startup grace period, counter % 30 as a 30-second
    heartbeat, counter % 10 as a 10-second config-hash check) was wrong by the same
    factor. Replaced with sleep 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 plain ai c
    re-attach no longer makes the running wrapper exec a 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 a tmux list-sessions subprocess plus a write and a chmod per
    live session for as long as the caller keeps asking.